mirror of
https://github.com/kovetskiy/mark.git
synced 2026-05-02 05:12:35 +00:00
feat: add GitHub Alerts transformer and renderers
Co-Authored-By: Manuel Rüger <manuel@rueg.eu>
This commit is contained in:
@@ -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
|
||||
// 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 {
|
||||
|
||||
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
|
||||
// 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 {
|
||||
var t = None
|
||||
var legacyClassifier = LegacyBlockQuoteClassifier()
|
||||
var ghAlertsClassifier = GHAlertsBlockQuoteClassifier()
|
||||
|
||||
countParagraphs := 0
|
||||
_ = 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 {
|
||||
n := node.(*ast.Text)
|
||||
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
|
||||
}
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
html.Config
|
||||
softBreak rune
|
||||
}
|
||||
|
||||
// NewConfluenceTextRenderer creates a new instance of the ConfluenceTextRenderer
|
||||
func NewConfluenceTextRenderer(stripNL bool, opts ...html.Option) renderer.NodeRenderer {
|
||||
// NewConfluenceTextRenderer creates a new instance of the renderer with GitHub Alerts support
|
||||
func NewConfluenceTextRenderer(stripNewlines bool, opts ...html.Option) renderer.NodeRenderer {
|
||||
sb := '\n'
|
||||
if stripNL {
|
||||
if stripNewlines {
|
||||
sb = ' '
|
||||
}
|
||||
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) {
|
||||
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
|
||||
// renderText handles text rendering and supports GitHub Alerts replacement content.
|
||||
// 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) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
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
|
||||
if n.IsRaw() {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user