mirror of
https://github.com/kovetskiy/mark.git
synced 2026-05-03 14:47:38 +08:00
feat: add GitHub Alerts transformer and renderers
Co-Authored-By: Manuel Rüger <manuel@rueg.eu>
This commit is contained in:
parent
b1de69c46a
commit
1c1eeb84fb
43
README.md
43
README.md
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
312
markdown/transformer_comparison_test.go
Normal file
312
markdown/transformer_comparison_test.go
Normal 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)
|
||||||
|

|
||||||
|
[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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
150
renderer/gh_alerts_blockquote.go
Normal file
150
renderer/gh_alerts_blockquote.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
90
renderer/text_legacy.go
Normal 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
|
||||||
|
}
|
||||||
10
testdata/quotes-droph1.html
vendored
10
testdata/quotes-droph1.html
vendored
@ -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>
|
||||||
|
|||||||
10
testdata/quotes-stripnewlines.html
vendored
10
testdata/quotes-stripnewlines.html
vendored
@ -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
10
testdata/quotes.html
vendored
@ -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
99
transformer/README.md
Normal 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
143
transformer/gh_alerts.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user