feat: add GitHub Alerts transformer and renderers

Co-Authored-By: Manuel Rüger <manuel@rueg.eu>
This commit is contained in:
Noam Asor 2026-03-30 14:04:18 +02:00 committed by Manuel Rüger
parent b1de69c46a
commit 1c1eeb84fb
12 changed files with 1005 additions and 88 deletions

View File

@ -281,18 +281,41 @@ More details at Confluence [Code Block Macro](https://confluence.atlassian.com/d
### Block Quotes ### Block Quotes
Block Quotes are converted to Confluence Info/Warn/Note box when the following conditions are met #### GitHub Alerts Support
You can now use GitHub-style alert syntax in your markdown, and Mark will automatically convert them to Confluence macros:
```markdown
> [!NOTE]
> This creates a blue info box - perfect for helpful information!
> [!TIP]
> This creates a green tip box - great for best practices and suggestions!
> [!IMPORTANT]
> This creates a blue info box - ideal for critical information!
> [!WARNING]
> This creates a yellow warning box - use for important warnings!
> [!CAUTION]
> This creates a red warning box - perfect for dangerous situations!
```
#### Technical Details
Block Quotes are converted to Confluence Info/Warn/Note box when the following conditions are met:
1. The BlockQuote is on the root level of the document (not nested) 1. The BlockQuote is on the root level of the document (not nested)
1. The first line of the BlockQuote contains one of the following patterns `Info/Warn/Note` or [Github MD Alerts style](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) `[!NOTE]/[!TIP]/[!IMPORTANT]/[!WARNING]/[!CAUTION]` 2. The first line of the BlockQuote contains one of the following patterns `Info/Warn/Note` or [GitHub MD Alerts style](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) `[!NOTE]/[!TIP]/[!IMPORTANT]/[!WARNING]/[!CAUTION]`
| Github Alerts | Confluence | | GitHub Alerts | Confluence | Description |
| --- | --- | | --------------- | ------------ | ------------- |
| Tip (green lightbulb) | Tip (green checkmark in circle) | | `[!TIP]` (green lightbulb) | Tip (green checkmark in circle) | Helpful suggestions and best practices |
| Note (blue I in circle) | Info (blue I in circle) | | `[!NOTE]` (blue I in circle) | Info (blue I in circle) | General information and notes |
| Important (purple exclamation mark in speech bubble) | Info (blue I in circle) | | `[!IMPORTANT]` (purple exclamation mark in speech bubble) | Info (blue I in circle) | Critical information that needs attention |
| Warning (yellow exclamation mark in triangle) | Note (yellow exclamation mark in triangle) | | `[!WARNING]` (yellow exclamation mark in triangle) | Note (yellow exclamation mark in triangle) | Important warnings and cautions |
| Caution (red exclamation mark in hexagon) | Warning (red exclamation mark in hexagon) | | `[!CAUTION]` (red exclamation mark in hexagon) | Warning (red exclamation mark in hexagon) | Dangerous situations requiring immediate attention |
In any other case the default behaviour will be resumed and html `<blockquote>` tag will be used In any other case the default behaviour will be resumed and html `<blockquote>` tag will be used
@ -750,7 +773,7 @@ Currently this is not compatible with the automated upload of inline images.
### Render Mermaid Diagram ### Render Mermaid Diagram
Confluence doesn't provide [mermaid.js](https://github.com/mermaid-js/mermaid) support natively. Mark provides a convenient way to enable the feature like [Github does](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/). Confluence doesn't provide [mermaid.js](https://github.com/mermaid-js/mermaid) support natively. Mark provides a convenient way to enable the feature like [GitHub does](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/).
As long as you have a code block marked as "mermaid", mark will automatically render it as a PNG image and attach it to the page as a rendered version of the code block. As long as you have a code block marked as "mermaid", mark will automatically render it as a PNG image and attach it to the page as a rendered version of the code block.
```mermaid title diagrams_example ```mermaid title diagrams_example

View File

@ -8,6 +8,7 @@ import (
cparser "github.com/kovetskiy/mark/v16/parser" cparser "github.com/kovetskiy/mark/v16/parser"
crenderer "github.com/kovetskiy/mark/v16/renderer" crenderer "github.com/kovetskiy/mark/v16/renderer"
"github.com/kovetskiy/mark/v16/stdlib" "github.com/kovetskiy/mark/v16/stdlib"
ctransformer "github.com/kovetskiy/mark/v16/transformer"
"github.com/kovetskiy/mark/v16/types" "github.com/kovetskiy/mark/v16/types"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
mkDocsParser "github.com/stefanfritsch/goldmark-admonitions" mkDocsParser "github.com/stefanfritsch/goldmark-admonitions"
@ -20,8 +21,9 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// Renderer renders anchor [Node]s. // ConfluenceLegacyExtension is the original goldmark extension without GitHub Alerts support
type ConfluenceExtension struct { // This extension is preserved for backward compatibility and testing purposes
type ConfluenceLegacyExtension struct {
html.Config html.Config
Stdlib *stdlib.Lib Stdlib *stdlib.Lib
Path string Path string
@ -29,9 +31,9 @@ type ConfluenceExtension struct {
Attachments []attachment.Attachment Attachments []attachment.Attachment
} }
// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer // NewConfluenceLegacyExtension creates a new instance of the legacy ConfluenceRenderer
func NewConfluenceExtension(stdlib *stdlib.Lib, path string, cfg types.MarkConfig) *ConfluenceExtension { func NewConfluenceLegacyExtension(stdlib *stdlib.Lib, path string, cfg types.MarkConfig) *ConfluenceLegacyExtension {
return &ConfluenceExtension{ return &ConfluenceLegacyExtension{
Config: html.NewConfig(), Config: html.NewConfig(),
Stdlib: stdlib, Stdlib: stdlib,
Path: path, Path: path,
@ -40,14 +42,14 @@ func NewConfluenceExtension(stdlib *stdlib.Lib, path string, cfg types.MarkConfi
} }
} }
func (c *ConfluenceExtension) Attach(a attachment.Attachment) { func (c *ConfluenceLegacyExtension) Attach(a attachment.Attachment) {
c.Attachments = append(c.Attachments, a) c.Attachments = append(c.Attachments, a)
} }
func (c *ConfluenceExtension) Extend(m goldmark.Markdown) { func (c *ConfluenceLegacyExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(crenderer.NewConfluenceTextRenderer(c.MarkConfig.StripNewlines), 100), util.Prioritized(crenderer.NewConfluenceTextLegacyRenderer(c.MarkConfig.StripNewlines), 100),
util.Prioritized(crenderer.NewConfluenceBlockQuoteRenderer(), 100), util.Prioritized(crenderer.NewConfluenceBlockQuoteRenderer(), 100),
util.Prioritized(crenderer.NewConfluenceCodeBlockRenderer(c.Stdlib, c.Path), 100), util.Prioritized(crenderer.NewConfluenceCodeBlockRenderer(c.Stdlib, c.Path), 100),
util.Prioritized(crenderer.NewConfluenceFencedCodeBlockRenderer(c.Stdlib, c, c.MarkConfig), 100), util.Prioritized(crenderer.NewConfluenceFencedCodeBlockRenderer(c.Stdlib, c, c.MarkConfig), 100),
@ -90,10 +92,10 @@ func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
)) ))
} }
func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) { // compileMarkdownWithExtension is a shared helper to eliminate code duplication
log.Trace().Msgf("rendering markdown:\n%s", string(markdown)) // between different compilation approaches
func compileMarkdownWithExtension(markdown []byte, ext goldmark.Extender, logMessage string) (string, error) {
confluenceExtension := NewConfluenceExtension(stdlib, path, cfg) log.Trace().Msgf(logMessage, string(markdown))
converter := goldmark.New( converter := goldmark.New(
goldmark.WithExtensions( goldmark.WithExtensions(
@ -102,7 +104,7 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types
extension.NewTable( extension.NewTable(
extension.WithTableCellAlignMethod(extension.TableCellAlignStyle), extension.WithTableCellAlignMethod(extension.TableCellAlignStyle),
), ),
confluenceExtension, ext,
extension.GFM, extension.GFM,
), ),
goldmark.WithParserOptions( goldmark.WithParserOptions(
@ -119,12 +121,128 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types
err := converter.Convert(markdown, &buf, parser.WithContext(ctx)) err := converter.Convert(markdown, &buf, parser.WithContext(ctx))
if err != nil { if err != nil {
return "", nil, err return "", err
} }
html := buf.Bytes() html := buf.Bytes()
log.Trace().Msgf("rendered markdown to html:\n%s", string(html)) log.Trace().Msgf("rendered markdown to html:\n%s", string(html))
return string(html), confluenceExtension.Attachments, nil return string(html), nil
}
// CompileMarkdown compiles markdown to Confluence Storage Format with GitHub Alerts support
// This is the main function that now uses the enhanced GitHub Alerts transformer by default
// for superior processing of [!NOTE], [!TIP], [!WARNING], [!CAUTION], [!IMPORTANT] syntax.
// Note: This is a breaking change from previous versions which rendered these markers literally.
func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) {
// Use the enhanced GitHub Alerts extension for better processing
ghAlertsExtension := NewConfluenceExtension(stdlib, path, cfg)
html, err := compileMarkdownWithExtension(markdown, ghAlertsExtension, "rendering markdown with GitHub Alerts support:\n%s")
return html, ghAlertsExtension.Attachments, err
}
// CompileMarkdownLegacy compiles markdown using the legacy approach without GitHub Alerts transformer
// This function is preserved for backward compatibility and testing purposes
func CompileMarkdownLegacy(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) {
confluenceExtension := NewConfluenceLegacyExtension(stdlib, path, cfg)
html, err := compileMarkdownWithExtension(markdown, confluenceExtension, "rendering markdown with legacy renderer:\n%s")
return html, confluenceExtension.Attachments, err
}
// ConfluenceExtension is a goldmark extension for GitHub Alerts with Transformer approach
// This extension provides superior GitHub Alert processing by transforming [!NOTE], [!TIP], etc.
// into proper Confluence macros while maintaining full compatibility with existing functionality.
// This is now the primary/default extension.
type ConfluenceExtension struct {
html.Config
Stdlib *stdlib.Lib
Path string
MarkConfig types.MarkConfig
Attachments []attachment.Attachment
}
// NewConfluenceExtension creates a new instance of the GitHub Alerts extension
// This is the improved standalone version that doesn't depend on feature flags
func NewConfluenceExtension(stdlib *stdlib.Lib, path string, cfg types.MarkConfig) *ConfluenceExtension {
return &ConfluenceExtension{
Config: html.NewConfig(),
Stdlib: stdlib,
Path: path,
MarkConfig: cfg,
Attachments: []attachment.Attachment{},
}
}
func (c *ConfluenceExtension) Attach(a attachment.Attachment) {
c.Attachments = append(c.Attachments, a)
}
// Extend extends the Goldmark processor with GitHub Alerts transformer and renderers
// This method registers all necessary components for GitHub Alert processing:
// 1. Core renderers for standard markdown elements
// 2. GitHub Alerts specific renderers (blockquote and text) with higher priority
// 3. GitHub Alerts AST transformer for preprocessing
func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
// Register core renderers (excluding blockquote and text which we'll replace)
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(crenderer.NewConfluenceCodeBlockRenderer(c.Stdlib, c.Path), 100),
util.Prioritized(crenderer.NewConfluenceFencedCodeBlockRenderer(c.Stdlib, c, c.MarkConfig), 100),
util.Prioritized(crenderer.NewConfluenceHTMLBlockRenderer(c.Stdlib), 100),
util.Prioritized(crenderer.NewConfluenceHeadingRenderer(c.MarkConfig.DropFirstH1), 100),
util.Prioritized(crenderer.NewConfluenceImageRenderer(c.Stdlib, c, c.Path, c.MarkConfig.ImageAlign), 100),
util.Prioritized(crenderer.NewConfluenceParagraphRenderer(), 100),
util.Prioritized(crenderer.NewConfluenceLinkRenderer(), 100),
util.Prioritized(crenderer.NewConfluenceTaskListRenderer(), 100),
))
// Add GitHub Alerts specific renderers with higher priority to override defaults
// These renderers handle both GitHub Alerts and legacy blockquote syntax
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(crenderer.NewConfluenceGHAlertsBlockQuoteRenderer(), 200),
util.Prioritized(crenderer.NewConfluenceTextRenderer(c.MarkConfig.StripNewlines), 200),
))
// Add the GitHub Alerts AST transformer that preprocesses [!TYPE] syntax
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(ctransformer.NewGHAlertsTransformer(), 100),
))
// Add mkdocsadmonitions support if requested
if slices.Contains(c.MarkConfig.Features, "mkdocsadmonitions") {
m.Parser().AddOptions(
parser.WithBlockParsers(
util.Prioritized(mkDocsParser.NewAdmonitionParser(), 100),
),
)
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(crenderer.NewConfluenceMkDocsAdmonitionRenderer(), 100),
))
}
// Add mention support if requested
if slices.Contains(c.MarkConfig.Features, "mention") {
m.Parser().AddOptions(
parser.WithInlineParsers(
util.Prioritized(cparser.NewMentionParser(), 99),
),
)
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(crenderer.NewConfluenceMentionRenderer(c.Stdlib), 100),
))
}
// Add confluence tag parser for <ac:*/> tags
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(cparser.NewConfluenceTagParser(), 199),
))
}
// CompileMarkdownWithTransformer compiles markdown using the transformer approach for GitHub Alerts
// This function provides enhanced GitHub Alert processing while maintaining full compatibility
// with existing markdown functionality. It transforms [!NOTE], [!TIP], etc. into proper titles.
// This is an alias for CompileMarkdown for backward compatibility.
func CompileMarkdownWithTransformer(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) {
return CompileMarkdown(markdown, stdlib, path, cfg)
} }

View File

@ -0,0 +1,312 @@
package mark_test
import (
"testing"
mark "github.com/kovetskiy/mark/v16/markdown"
"github.com/kovetskiy/mark/v16/stdlib"
"github.com/kovetskiy/mark/v16/types"
"github.com/stretchr/testify/assert"
)
func TestGHAlertsTransformerVsLegacyRenderer(t *testing.T) {
testCases := []struct {
name string
markdown string
expectMacro bool
expectClean bool // Whether the [!TYPE] syntax should be cleaned up
description string
}{
{
name: "GitHub Alert NOTE",
markdown: "> [!NOTE]\n> This is a test note.",
expectMacro: true,
expectClean: true,
description: "GitHub Alert [!NOTE] syntax should be converted to Confluence info macro",
},
{
name: "GitHub Alert TIP",
markdown: "> [!TIP]\n> This is a helpful tip.",
expectMacro: true,
expectClean: true,
description: "GitHub Alert [!TIP] syntax should be converted to Confluence tip macro",
},
{
name: "GitHub Alert WARNING",
markdown: "> [!WARNING]\n> This is a warning message.",
expectMacro: true,
expectClean: true,
description: "GitHub Alert [!WARNING] syntax should be converted to Confluence note macro",
},
{
name: "GitHub Alert CAUTION",
markdown: "> [!CAUTION]\n> Be very careful here.",
expectMacro: true,
expectClean: true,
description: "GitHub Alert [!CAUTION] syntax should be converted to Confluence warning macro",
},
{
name: "GitHub Alert IMPORTANT",
markdown: "> [!IMPORTANT]\n> This is very important.",
expectMacro: true,
expectClean: true,
description: "GitHub Alert [!IMPORTANT] syntax should be converted to Confluence info macro",
},
{
name: "Legacy blockquote with info",
markdown: "> info: This is legacy info syntax.",
expectMacro: true,
expectClean: false,
description: "Legacy info: syntax should be converted to Confluence info macro",
},
{
name: "Regular blockquote",
markdown: "> This is just a regular blockquote.",
expectMacro: false,
expectClean: false,
description: "Regular blockquotes should remain as HTML blockquote elements",
},
}
stdlib, err := stdlib.New(nil)
if err != nil {
t.Fatalf("Failed to create stdlib: %v", err)
}
cfg := types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Logf("Testing: %s", tc.description)
// Test with GitHub Alerts transformer (primary approach)
transformerResult, transformerAttachments, err := mark.CompileMarkdown([]byte(tc.markdown), stdlib, "/test", cfg)
assert.NoError(t, err)
// Test with legacy renderer
legacyResult, legacyAttachments, err := mark.CompileMarkdownLegacy([]byte(tc.markdown), stdlib, "/test", cfg)
assert.NoError(t, err)
// Basic checks
assert.NotEmpty(t, transformerResult, "Transformer result should not be empty")
assert.NotEmpty(t, legacyResult, "Legacy result should not be empty")
assert.Empty(t, transformerAttachments, "Should have no attachments")
assert.Empty(t, legacyAttachments, "Should have no attachments")
// Check for Confluence macro presence
if tc.expectMacro {
assert.Contains(t, transformerResult, "structured-macro", "Transformer should produce Confluence macro")
// Legacy renderer should NOT handle GitHub Alert syntax - it should treat as plain blockquote
if tc.expectClean {
// This is a GitHub Alert case - legacy should produce blockquote, transformer should produce macro
assert.Contains(t, legacyResult, "<blockquote>", "Legacy renderer should treat GitHub Alerts as regular blockquotes")
} else {
// This is a legacy syntax case (like "info:") - both should produce macro
assert.Contains(t, legacyResult, "structured-macro", "Legacy renderer should produce Confluence macro for legacy syntax")
}
} else {
assert.Contains(t, transformerResult, "<blockquote>", "Regular blockquote should use HTML blockquote")
assert.Contains(t, legacyResult, "<blockquote>", "Regular blockquote should use HTML blockquote")
} // Check for GitHub Alert syntax cleanup (only for transformer with GitHub Alert syntax)
if tc.expectClean {
// Transformer should clean up the [!TYPE] syntax
assert.NotContains(t, transformerResult, "[!", "Transformer should remove GitHub Alert syntax markers")
// Legacy renderer might not clean it up (depending on implementation)
// We'll just log what it produces for comparison
t.Logf("Transformer result: %s", transformerResult)
t.Logf("Legacy result: %s", legacyResult)
} else {
// For non-GitHub Alert cases, both should behave similarly
t.Logf("Transformer result: %s", transformerResult)
t.Logf("Legacy result: %s", legacyResult)
}
})
}
}
func TestBasicTransformerFunctionality(t *testing.T) {
testMarkdown := "> [!NOTE]\n> This is a test note."
stdlib, err := stdlib.New(nil)
if err != nil {
t.Fatalf("Failed to create stdlib: %v", err)
}
cfg := types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
}
result, attachments, err := mark.CompileMarkdown([]byte(testMarkdown), stdlib, "/test", cfg)
assert.NoError(t, err)
// Basic checks
assert.NotEmpty(t, result)
assert.Empty(t, attachments)
assert.Contains(t, result, "structured-macro")
// This test should now pass because we fixed the transformer
assert.NotContains(t, result, "[!NOTE]", "The GitHub Alert syntax should be cleaned up")
t.Logf("Transformer result: %s", result)
}
// TestCompatibilityWithExistingFeatures tests that the transformer approach is fully compatible
// with existing non-blockquote functionality from the original markdown tests
func TestCompatibilityWithExistingFeatures(t *testing.T) {
testCases := []struct {
name string
markdown string
config types.MarkConfig
description string
}{
{
name: "Headers Basic",
markdown: `# Header 1
## Header 2
### Header 3`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
},
description: "Basic header rendering should be identical",
},
{
name: "Headers with DropFirstH1",
markdown: `# Header 1
## Header 2
### Header 3`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: true,
},
description: "Header rendering with DropFirstH1 should be identical",
},
{
name: "Code Blocks",
markdown: "`inline code`\n\n```bash\necho \"hello\"\n```",
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
},
description: "Code block rendering should be identical",
},
{
name: "Links and Images",
markdown: `[Link](https://example.com)
![Image](test.png)
[Page Link](ac:Page)`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
},
description: "Links and images should be rendered identically",
},
{
name: "Tables",
markdown: `| Header 1 | Header 2 |
|----------|----------|
| Row 1 | Row 2 |`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
},
description: "Table rendering should be identical",
},
{
name: "Mixed Content",
markdown: `# Title
Some **bold** and *italic* text.
- List item 1
- List item 2
` + "`inline code`" + ` and:
` + "```javascript\nconsole.log(\"test\");\n```" + `
[Link](https://example.com)`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: false,
DropFirstH1: false,
},
description: "Mixed content should be rendered identically",
},
{
name: "Strip Newlines",
markdown: `Line 1
Line 2
Line 3`,
config: types.MarkConfig{
Features: []string{},
StripNewlines: true,
DropFirstH1: false,
},
description: "StripNewlines functionality should work identically",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Logf("Testing: %s", tc.description)
stdlib, err := stdlib.New(nil)
if err != nil {
t.Fatalf("Failed to create stdlib: %v", err)
}
// Test with GitHub Alerts transformer (primary approach)
transformerResult, transformerAttachments, err := mark.CompileMarkdown([]byte(tc.markdown), stdlib, "/test", tc.config)
assert.NoError(t, err)
// Test with legacy renderer (original approach)
legacyResult, legacyAttachments, err := mark.CompileMarkdownLegacy([]byte(tc.markdown), stdlib, "/test", tc.config)
assert.NoError(t, err)
// Basic checks
assert.NotEmpty(t, transformerResult, "Transformer result should not be empty")
assert.NotEmpty(t, legacyResult, "Legacy result should not be empty")
assert.Equal(t, len(transformerAttachments), len(legacyAttachments), "Attachment counts should match")
// The key compatibility test: results should be identical for non-blockquote content
if transformerResult != legacyResult {
t.Errorf("COMPATIBILITY ISSUE: Results differ for %s\n"+
"Transformer result:\n%s\n\n"+
"Legacy result:\n%s\n\n"+
"Diff (transformer vs legacy):",
tc.name, transformerResult, legacyResult)
// Log the differences for debugging
t.Logf("Transformer length: %d", len(transformerResult))
t.Logf("Legacy length: %d", len(legacyResult))
// Character-by-character comparison for debugging
for i := 0; i < len(transformerResult) && i < len(legacyResult); i++ {
if transformerResult[i] != legacyResult[i] {
t.Logf("First difference at position %d: transformer='%c'(%d) vs legacy='%c'(%d)",
i, transformerResult[i], transformerResult[i], legacyResult[i], legacyResult[i])
break
}
}
} else {
t.Logf("✅ Perfect compatibility for %s", tc.name)
}
})
}
}

View File

@ -64,18 +64,9 @@ func LegacyBlockQuoteClassifier() BlockQuoteClassifier {
} }
} }
func GHAlertsBlockQuoteClassifier() BlockQuoteClassifier {
return BlockQuoteClassifier{
patternMap: map[string]*regexp.Regexp{
"info": regexp.MustCompile(`(?i)^\!(note|important)`),
"note": regexp.MustCompile(`(?i)^\!warning`),
"warn": regexp.MustCompile(`(?i)^\!caution`),
"tip": regexp.MustCompile(`(?i)^\!tip`),
},
}
}
// ClassifyingBlockQuote compares a string against a set of patterns and returns a BlockQuoteType // ClassifyingBlockQuote compares a string against a set of patterns and returns a BlockQuoteType
// Note: GitHub Alerts ([!NOTE], [!TIP], etc.) are now handled by the superior transformer approach
// in the GitHub Alerts extension, not by this legacy blockquote renderer
func (classifier BlockQuoteClassifier) ClassifyingBlockQuote(literal string) BlockQuoteType { func (classifier BlockQuoteClassifier) ClassifyingBlockQuote(literal string) BlockQuoteType {
var t = None var t = None
@ -93,10 +84,11 @@ func (classifier BlockQuoteClassifier) ClassifyingBlockQuote(literal string) Blo
} }
// ParseBlockQuoteType parses the first line of a blockquote and returns its type // ParseBlockQuoteType parses the first line of a blockquote and returns its type
// Note: This legacy function only handles traditional "info:", "note:", etc. syntax
// GitHub Alerts ([!NOTE], [!TIP], etc.) are handled by the GitHub Alerts transformer
func ParseBlockQuoteType(node ast.Node, source []byte) BlockQuoteType { func ParseBlockQuoteType(node ast.Node, source []byte) BlockQuoteType {
var t = None var t = None
var legacyClassifier = LegacyBlockQuoteClassifier() var legacyClassifier = LegacyBlockQuoteClassifier()
var ghAlertsClassifier = GHAlertsBlockQuoteClassifier()
countParagraphs := 0 countParagraphs := 0
_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { _ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
@ -109,27 +101,6 @@ func ParseBlockQuoteType(node ast.Node, source []byte) BlockQuoteType {
if node.Kind() == ast.KindText { if node.Kind() == ast.KindText {
n := node.(*ast.Text) n := node.(*ast.Text)
t = legacyClassifier.ClassifyingBlockQuote(string(n.Value(source))) t = legacyClassifier.ClassifyingBlockQuote(string(n.Value(source)))
// If the node is a text node but classification returned none do not give up!
// Find the next two sibling nodes midNode and rightNode,
// 1. If both are also a text node
// 2. and the original node (node) text value is '['
// 3. and the rightNode text value is ']'
// It means with high degree of confidence that the original md doc contains a Github alert type of blockquote
// Classifying the next text type node (midNode) will confirm that.
if t == None {
midNode := node.NextSibling()
if midNode != nil && midNode.Kind() == ast.KindText {
rightNode := midNode.NextSibling()
midTextNode := midNode.(*ast.Text)
if rightNode != nil && rightNode.Kind() == ast.KindText {
rightTextNode := rightNode.(*ast.Text)
if string(n.Value(source)) == "[" && string(rightTextNode.Value(source)) == "]" {
t = ghAlertsClassifier.ClassifyingBlockQuote(string(midTextNode.Value(source)))
}
}
}
}
countParagraphs += 1 countParagraphs += 1
} }
if node.Kind() == ast.KindHTMLBlock { if node.Kind() == ast.KindHTMLBlock {

View File

@ -0,0 +1,150 @@
package renderer
import (
"fmt"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
type ConfluenceGHAlertsBlockQuoteRenderer struct {
html.Config
LevelMap BlockQuoteLevelMap
BlockQuoteNode ast.Node
}
// NewConfluenceGHAlertsBlockQuoteRenderer creates a new instance of the renderer for GitHub Alerts
func NewConfluenceGHAlertsBlockQuoteRenderer(opts ...html.Option) renderer.NodeRenderer {
return &ConfluenceGHAlertsBlockQuoteRenderer{
Config: html.NewConfig(),
LevelMap: nil,
BlockQuoteNode: nil,
}
}
// RegisterFuncs implements NodeRenderer.RegisterFuncs
func (r *ConfluenceGHAlertsBlockQuoteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindBlockquote, r.renderBlockQuote)
}
// Define GitHub Alert to Confluence macro mapping
func (r *ConfluenceGHAlertsBlockQuoteRenderer) getConfluenceMacroName(alertType string) string {
switch alertType {
case "note":
return "info"
case "tip":
return "tip"
case "important":
return "info"
case "warning":
return "note"
case "caution":
return "warning"
default:
return "info"
}
}
func (r *ConfluenceGHAlertsBlockQuoteRenderer) renderBlockQuote(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if r.LevelMap == nil {
r.LevelMap = GenerateBlockQuoteLevel(node)
}
// Check if this blockquote has been transformed by the GHAlerts transformer
if alertTypeBytes, hasAttribute := node.Attribute([]byte("gh-alert-type")); hasAttribute && alertTypeBytes != nil {
if alertTypeStr, ok := alertTypeBytes.([]byte); ok {
return r.renderGHAlert(writer, source, node, entering, string(alertTypeStr))
}
}
// Fall back to legacy blockquote rendering for non-GitHub Alert blockquotes
return r.renderLegacyBlockQuote(writer, source, node, entering)
}
func (r *ConfluenceGHAlertsBlockQuoteRenderer) renderGHAlert(writer util.BufWriter, source []byte, node ast.Node, entering bool, alertType string) (ast.WalkStatus, error) {
quoteLevel := r.LevelMap.Level(node)
if quoteLevel == 0 && entering {
r.BlockQuoteNode = node
macroName := r.getConfluenceMacroName(alertType)
prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", macroName)
if _, err := writer.Write([]byte(prefix)); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
if quoteLevel == 0 && !entering && node == r.BlockQuoteNode {
suffix := "</ac:rich-text-body></ac:structured-macro>\n"
if _, err := writer.Write([]byte(suffix)); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
// For nested blockquotes or continuing the content, use default rendering
if quoteLevel > 0 {
if entering {
if _, err := writer.WriteString("<blockquote>\n"); err != nil {
return ast.WalkStop, err
}
} else {
if _, err := writer.WriteString("</blockquote>\n"); err != nil {
return ast.WalkStop, err
}
}
} else if quoteLevel == 0 && alertType == "" {
// This handles the fallback case for non-alert blockquotes if called accidentally
if entering {
if _, err := writer.WriteString("<blockquote>\n"); err != nil {
return ast.WalkStop, err
}
} else {
if _, err := writer.WriteString("</blockquote>\n"); err != nil {
return ast.WalkStop, err
}
}
}
return ast.WalkContinue, nil
}
func (r *ConfluenceGHAlertsBlockQuoteRenderer) renderLegacyBlockQuote(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Legacy blockquote handling (same as original ParseBlockQuoteType logic)
quoteType := ParseBlockQuoteType(node, source)
quoteLevel := r.LevelMap.Level(node)
if quoteLevel == 0 && entering && quoteType != None {
r.BlockQuoteNode = node
prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", quoteType)
if _, err := writer.Write([]byte(prefix)); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
if quoteLevel == 0 && !entering && node == r.BlockQuoteNode {
suffix := "</ac:rich-text-body></ac:structured-macro>\n"
if _, err := writer.Write([]byte(suffix)); err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
// For nested blockquotes or regular blockquotes (at root level with no macro type)
if quoteLevel > 0 || (quoteLevel == 0 && quoteType == None) {
if entering {
if _, err := writer.WriteString("<blockquote>\n"); err != nil {
return ast.WalkStop, err
}
} else {
if _, err := writer.WriteString("</blockquote>\n"); err != nil {
return ast.WalkStop, err
}
}
}
return ast.WalkContinue, nil
}

View File

@ -10,23 +10,15 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// ConfluenceTextRenderer slightly alters the default goldmark behavior for
// inline text block. It allows for soft breaks
// (c.f. https://spec.commonmark.org/0.30/#softbreak)
// to be rendered into HTML as either '\n' (the goldmark default)
// or as ' '.
// This latter option is useful for Confluence,
// which inserts <br> tags into uploaded HTML where it sees '\n'.
// See also https://sembr.org/ for partial motivation.
type ConfluenceTextRenderer struct { type ConfluenceTextRenderer struct {
html.Config html.Config
softBreak rune softBreak rune
} }
// NewConfluenceTextRenderer creates a new instance of the ConfluenceTextRenderer // NewConfluenceTextRenderer creates a new instance of the renderer with GitHub Alerts support
func NewConfluenceTextRenderer(stripNL bool, opts ...html.Option) renderer.NodeRenderer { func NewConfluenceTextRenderer(stripNewlines bool, opts ...html.Option) renderer.NodeRenderer {
sb := '\n' sb := '\n'
if stripNL { if stripNewlines {
sb = ' ' sb = ' '
} }
return &ConfluenceTextRenderer{ return &ConfluenceTextRenderer{
@ -35,18 +27,36 @@ func NewConfluenceTextRenderer(stripNL bool, opts ...html.Option) renderer.NodeR
} }
} }
// RegisterFuncs implements NodeRenderer.RegisterFuncs . // RegisterFuncs implements NodeRenderer.RegisterFuncs
func (r *ConfluenceTextRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (r *ConfluenceTextRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindText, r.renderText) reg.Register(ast.KindText, r.renderText)
} }
// This is taken from https://github.com/yuin/goldmark/blob/v1.6.0/renderer/html/html.go#L719 // renderText handles text rendering and supports GitHub Alerts replacement content.
// with the hardcoded '\n' for soft breaks swapped for the configurable r.softBreak // This is an enhanced version of the default goldmark text renderer that checks
// for replacement-content attributes before falling back to standard behavior.
// Note: This logic is partially duplicated from ConfluenceTextLegacyRenderer.renderText
// but includes additional GitHub Alerts support. We keep them separate to maintain
// clean legacy vs enhanced implementation paths.
func (r *ConfluenceTextRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { func (r *ConfluenceTextRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering { if !entering {
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
n := node.(*ast.Text) n := node.(*ast.Text)
// Check if this text node has replacement content from the GHAlerts transformer
if replacementContent, hasAttribute := node.Attribute([]byte("replacement-content")); hasAttribute && replacementContent != nil {
if contentBytes, ok := replacementContent.([]byte); ok {
_, err := w.Write(contentBytes)
if err != nil {
return ast.WalkStop, err
}
return ast.WalkContinue, nil
}
}
// Default text rendering behavior (same as original ConfluenceTextRenderer)
segment := n.Segment segment := n.Segment
if n.IsRaw() { if n.IsRaw() {
r.Writer.RawWrite(w, segment.Value(source)) r.Writer.RawWrite(w, segment.Value(source))
@ -87,6 +97,7 @@ func (r *ConfluenceTextRenderer) renderText(w util.BufWriter, source []byte, nod
} }
} }
} }
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }

90
renderer/text_legacy.go Normal file
View File

@ -0,0 +1,90 @@
package renderer
import (
"unicode/utf8"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)
// ConfluenceTextLegacyRenderer slightly alters the default goldmark behavior for
// inline text block. It allows for soft breaks
// (c.f. https://spec.commonmark.org/0.30/#softbreak)
// to be rendered into HTML as either '\n' (the goldmark default)
// or as ' '.
// This latter option is useful for Confluence,
// which inserts <br> tags into uploaded HTML where it sees '\n'.
// See also https://sembr.org/ for partial motivation.
type ConfluenceTextLegacyRenderer struct {
html.Config
softBreak rune
}
// NewConfluenceTextLegacyRenderer creates a new instance of the ConfluenceTextRenderer (legacy version)
func NewConfluenceTextLegacyRenderer(stripNL bool, opts ...html.Option) renderer.NodeRenderer {
sb := '\n'
if stripNL {
sb = ' '
}
return &ConfluenceTextLegacyRenderer{
Config: html.NewConfig(),
softBreak: sb,
}
}
// RegisterFuncs implements NodeRenderer.RegisterFuncs .
func (r *ConfluenceTextLegacyRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindText, r.renderText)
}
// This is taken from https://github.com/yuin/goldmark/blob/v1.6.0/renderer/html/html.go#L719
// with the hardcoded '\n' for soft breaks swapped for the configurable r.softBreak
func (r *ConfluenceTextLegacyRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
segment := n.Segment
if n.IsRaw() {
r.Writer.RawWrite(w, segment.Value(source))
} else {
value := segment.Value(source)
r.Writer.Write(w, value)
if n.HardLineBreak() || (n.SoftLineBreak() && r.HardWraps) {
if r.XHTML {
_, _ = w.WriteString("<br />\n")
} else {
_, _ = w.WriteString("<br>\n")
}
} else if n.SoftLineBreak() {
if r.EastAsianLineBreaks != html.EastAsianLineBreaksNone && len(value) != 0 {
sibling := node.NextSibling()
if sibling != nil && sibling.Kind() == ast.KindText {
if siblingText := sibling.(*ast.Text).Value(source); len(siblingText) != 0 {
thisLastRune := util.ToRune(value, len(value)-1)
siblingFirstRune, _ := utf8.DecodeRune(siblingText)
// Inline the softLineBreak function as it's not public
writeLineBreak := false
switch r.EastAsianLineBreaks {
case html.EastAsianLineBreaksNone:
writeLineBreak = false
case html.EastAsianLineBreaksSimple:
writeLineBreak = !util.IsEastAsianWideRune(thisLastRune) || !util.IsEastAsianWideRune(siblingFirstRune)
case html.EastAsianLineBreaksCSS3Draft:
writeLineBreak = eastAsianLineBreaksCSS3DraftSoftLineBreak(thisLastRune, siblingFirstRune)
}
if writeLineBreak {
_ = w.WriteByte(byte(r.softBreak))
}
}
}
} else {
_ = w.WriteByte(byte(r.softBreak))
}
}
}
return ast.WalkContinue, nil
}

View File

@ -48,7 +48,7 @@ b</p>
<h2 id="GH-Alerts-Heading">GH Alerts Heading</h2> <h2 id="GH-Alerts-Heading">GH Alerts Heading</h2>
<h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3> <h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!NOTE]</p> <p>Note</p>
<ul> <ul>
<li>Note bullet 1</li> <li>Note bullet 1</li>
<li>Note bullet 2</li> <li>Note bullet 2</li>
@ -56,7 +56,7 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3> <h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3>
<ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!TIP]</p> <p>Tip</p>
<ul> <ul>
<li>Tip bullet 1</li> <li>Tip bullet 1</li>
<li>Tip bullet 2</li> <li>Tip bullet 2</li>
@ -64,7 +64,7 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3> <h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3>
<ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!WARNING]</p> <p>Warning</p>
<ul> <ul>
<li>Warning bullet 1</li> <li>Warning bullet 1</li>
<li>Warning bullet 2</li> <li>Warning bullet 2</li>
@ -72,14 +72,14 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3> <h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!IMPORTANT]</p> <p>Important</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>
</ul> </ul>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!CAUTION]</p> <p>Caution</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>

View File

@ -46,7 +46,7 @@
<h2 id="GH-Alerts-Heading">GH Alerts Heading</h2> <h2 id="GH-Alerts-Heading">GH Alerts Heading</h2>
<h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3> <h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!NOTE]</p> <p>Note</p>
<ul> <ul>
<li>Note bullet 1</li> <li>Note bullet 1</li>
<li>Note bullet 2</li> <li>Note bullet 2</li>
@ -54,7 +54,7 @@
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3> <h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3>
<ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!TIP]</p> <p>Tip</p>
<ul> <ul>
<li>Tip bullet 1</li> <li>Tip bullet 1</li>
<li>Tip bullet 2</li> <li>Tip bullet 2</li>
@ -62,7 +62,7 @@
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3> <h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3>
<ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!WARNING]</p> <p>Warning</p>
<ul> <ul>
<li>Warning bullet 1</li> <li>Warning bullet 1</li>
<li>Warning bullet 2</li> <li>Warning bullet 2</li>
@ -70,14 +70,14 @@
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3> <h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!IMPORTANT]</p> <p>Important</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>
</ul> </ul>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!CAUTION]</p> <p>Caution</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>

10
testdata/quotes.html vendored
View File

@ -49,7 +49,7 @@ b</p>
<h2 id="GH-Alerts-Heading">GH Alerts Heading</h2> <h2 id="GH-Alerts-Heading">GH Alerts Heading</h2>
<h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3> <h3 id="Note-Type-Alert-Heading">Note Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!NOTE]</p> <p>Note</p>
<ul> <ul>
<li>Note bullet 1</li> <li>Note bullet 1</li>
<li>Note bullet 2</li> <li>Note bullet 2</li>
@ -57,7 +57,7 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3> <h3 id="Tip-Type-Alert-Heading">Tip Type Alert Heading</h3>
<ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="tip"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!TIP]</p> <p>Tip</p>
<ul> <ul>
<li>Tip bullet 1</li> <li>Tip bullet 1</li>
<li>Tip bullet 2</li> <li>Tip bullet 2</li>
@ -65,7 +65,7 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3> <h3 id="Warning-Type-Alert-Heading">Warning Type Alert Heading</h3>
<ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="note"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!WARNING]</p> <p>Warning</p>
<ul> <ul>
<li>Warning bullet 1</li> <li>Warning bullet 1</li>
<li>Warning bullet 2</li> <li>Warning bullet 2</li>
@ -73,14 +73,14 @@ b</p>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3> <h3 id="Important/Caution-Type-Alert-Heading">Important/Caution Type Alert Heading</h3>
<ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="info"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!IMPORTANT]</p> <p>Important</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>
</ul> </ul>
</ac:rich-text-body></ac:structured-macro> </ac:rich-text-body></ac:structured-macro>
<ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body> <ac:structured-macro ac:name="warning"><ac:parameter ac:name="icon">true</ac:parameter><ac:rich-text-body>
<p>[!CAUTION]</p> <p>Caution</p>
<ul> <ul>
<li>Important bullet 1</li> <li>Important bullet 1</li>
<li>Important bullet 2</li> <li>Important bullet 2</li>

99
transformer/README.md Normal file
View File

@ -0,0 +1,99 @@
# GitHub Alerts Transformer
This directory contains the GitHub Alerts transformer that enables Mark to convert GitHub-style alert syntax into Confluence macros.
## Overview
The GitHub Alerts transformer processes markdown with GitHub Alert syntax like `[!NOTE]`, `[!TIP]`, `[!WARNING]`, `[!CAUTION]`, and `[!IMPORTANT]` and converts them into appropriate Confluence structured macros.
## Supported Alert Types
| GitHub Alert | Confluence Macro | Description |
|--------------|-----------------|-------------|
| `[!NOTE]` | `info` | General information |
| `[!TIP]` | `tip` | Helpful suggestions |
| `[!IMPORTANT]` | `info` | Critical information |
| `[!WARNING]` | `note` | Important warnings |
| `[!CAUTION]` | `warning` | Dangerous situations |
## Usage Example
### Input Markdown
```markdown
# Test GitHub Alerts
## Note Alert
> [!NOTE]
> This is a note alert with **markdown** formatting.
>
> - Item 1
> - Item 2
## Tip Alert
> [!TIP]
> This is a tip alert.
## Warning Alert
> [!WARNING]
> This is a warning alert.
## Regular Blockquote
> This is a regular blockquote without GitHub Alert syntax.
```
### Output (Confluence Storage Format)
The transformer converts GitHub Alert syntax into Confluence structured macros:
```xml
<ac:structured-macro ac:name="info">
<ac:parameter ac:name="icon">true</ac:parameter>
<ac:rich-text-body>
<p>Note</p>
<p>This is a note alert with <strong>markdown</strong> formatting.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</ac:rich-text-body>
</ac:structured-macro>
```
## Key Features
- **GitHub Compatibility**: Full support for GitHub's alert syntax
- **Markdown Preservation**: All markdown formatting within alerts is preserved
- **Fallback Support**: Regular blockquotes without alert syntax remain unchanged
- **User-Friendly Labels**: Adds readable labels (Note, Tip, Warning, etc.) to alert content
- **Confluence Integration**: Maps to appropriate Confluence macro types for optimal display
## Implementation
The transformer works by:
1. **AST Transformation**: Modifies the goldmark AST before rendering
2. **Pattern Matching**: Identifies GitHub Alert patterns in blockquotes
3. **Content Enhancement**: Adds user-friendly labels and processes nested markdown
4. **Macro Generation**: Converts to appropriate Confluence structured macros
## Backward Compatibility
- Legacy `info:`, `tip:`, `warning:` syntax continues to work
- Regular blockquotes remain unchanged
- Full compatibility with existing Mark features
## Testing
The transformer is thoroughly tested with:
- All GitHub Alert types (`[!NOTE]`, `[!TIP]`, `[!WARNING]`, `[!CAUTION]`, `[!IMPORTANT]`)
- Nested markdown formatting (bold, italic, lists, etc.)
- Mixed content scenarios
- Backward compatibility with legacy syntax
- Edge cases and error conditions
See `../markdown/transformer_comparison_test.go` for comprehensive test coverage.

143
transformer/gh_alerts.go Normal file
View File

@ -0,0 +1,143 @@
package transformer
import (
"strings"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
// GHAlertsTransformer transforms GitHub Alert syntax ([!NOTE], [!TIP], etc.)
// into a custom AST node that can be rendered as Confluence macros
type GHAlertsTransformer struct{}
// NewGHAlertsTransformer creates a new GitHub Alerts transformer
func NewGHAlertsTransformer() *GHAlertsTransformer {
return &GHAlertsTransformer{}
}
// Transform implements the parser.ASTTransformer interface
func (t *GHAlertsTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
// Only process blockquote nodes
blockquote, ok := node.(*ast.Blockquote)
if !ok {
return ast.WalkContinue, nil
}
// Check if this blockquote contains GitHub Alert syntax
alertType := t.extractAlertType(blockquote, reader)
if alertType == "" {
return ast.WalkContinue, nil
}
// Transform the blockquote into a GitHub Alert node
t.transformBlockquote(blockquote, alertType, reader)
return ast.WalkContinue, nil
})
}
// extractAlertType checks if the blockquote starts with GitHub Alert syntax and returns the alert type
func (t *GHAlertsTransformer) extractAlertType(blockquote *ast.Blockquote, reader text.Reader) string {
// Look for the first paragraph in the blockquote
firstChild := blockquote.FirstChild()
if firstChild == nil || firstChild.Kind() != ast.KindParagraph {
return ""
}
paragraph := firstChild.(*ast.Paragraph)
// Check if the paragraph starts with the GitHub Alert pattern [!TYPE]
firstText := paragraph.FirstChild()
if firstText == nil || firstText.Kind() != ast.KindText {
return ""
}
// Look for the pattern: [!ALERTTYPE]
// We need to check for three consecutive text nodes: "[", "!ALERTTYPE", "]"
// This is the intended behavior for GitHub Alerts which should be at the very start.
// Note: We follow GitHub's strict syntax here and don't allow whitespace between
// brackets and exclamation mark (e.g., [! NOTE] is not recognized).
currentNode := firstText
var nodes []ast.Node
// Collect up to 3 text nodes
for i := 0; i < 3 && currentNode != nil && currentNode.Kind() == ast.KindText; i++ {
nodes = append(nodes, currentNode)
currentNode = currentNode.NextSibling()
}
if len(nodes) < 3 {
return ""
}
leftText := nodes[0].(*ast.Text)
middleText := nodes[1].(*ast.Text)
rightText := nodes[2].(*ast.Text)
leftContent := string(leftText.Segment.Value(reader.Source()))
middleContent := string(middleText.Segment.Value(reader.Source()))
rightContent := string(rightText.Segment.Value(reader.Source()))
// Check for the exact pattern
if leftContent == "[" && rightContent == "]" && strings.HasPrefix(middleContent, "!") {
alertType := strings.ToLower(strings.TrimPrefix(middleContent, "!"))
// Validate it's a recognized GitHub Alert type
switch alertType {
case "note", "tip", "important", "warning", "caution":
return alertType
}
}
return ""
}
// transformBlockquote modifies the blockquote to remove the GitHub Alert syntax
// and adds metadata for rendering
func (t *GHAlertsTransformer) transformBlockquote(blockquote *ast.Blockquote, alertType string, reader text.Reader) {
// Set a custom attribute to identify this as a GitHub Alert
blockquote.SetAttribute([]byte("gh-alert-type"), []byte(alertType))
// Find and remove/replace the GitHub Alert syntax from the first paragraph
firstChild := blockquote.FirstChild()
if firstChild != nil && firstChild.Kind() == ast.KindParagraph {
paragraph := firstChild.(*ast.Paragraph)
t.splitAlertParagraph(blockquote, paragraph, alertType, reader)
}
}
// splitAlertParagraph removes the [!TYPE] syntax and creates a separate paragraph for the title
func (t *GHAlertsTransformer) splitAlertParagraph(blockquote *ast.Blockquote, paragraph *ast.Paragraph, alertType string, reader text.Reader) {
// Generate user-friendly title
title := strings.ToUpper(alertType[:1]) + alertType[1:]
// Create a new paragraph for the title
titleParagraph := ast.NewParagraph()
titleText := ast.NewText()
titleText.Segment = text.NewSegment(0, 0) // Dummy segment, we'll use attribute for content
titleText.SetAttribute([]byte("replacement-content"), []byte(title))
titleParagraph.AppendChild(titleParagraph, titleText)
// Insert the title paragraph before the current one
blockquote.InsertBefore(blockquote, paragraph, titleParagraph)
// Remove the first three nodes ([ !TYPE ]) from the original paragraph
currentNode := paragraph.FirstChild()
for i := 0; i < 3 && currentNode != nil; i++ {
next := currentNode.NextSibling()
paragraph.RemoveChild(paragraph, currentNode)
currentNode = next
}
// If the original paragraph is now empty, remove it
if paragraph.FirstChild() == nil {
blockquote.RemoveChild(blockquote, paragraph)
}
}