Repository: gohugoio/hugo (language: go)

Issue:
Extend blockquote alerts to support Obsidian types
I've been trying out the new GitHub style alerts from #12590. Following the [new documentation](https://gohugo.io/render-hooks/blockquotes/#alerts) the alerts work great, thanks to @bep and @jmooring.

I have however noticed that the alert type's seem to be [hardcoded](
https://github.com/bep/hugo/commit/abfdb6c263cfe8e783bcb71683537d4e56131355#diff-bfcae9432cc0532f64a2cdf7f6d21301112aaf1b084cb749cbf82ba6b4558d5bR238) to only accept a few preset types (the five mentioned in GitHub's [alerts documentation](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)) - which makes sense because this ticket was for exactly that!

It would be quite helpful if we could define additional alert types. I use Obsidian and this change is great because Obsidian does alerts the same way as GitHub, but supports a few more alert types, see [here](https://help.obsidian.md/Editing+and+formatting/Callouts#Supported%20types) for the list (stuff like info, danger etc).

Would @bep consider adding functionality to allow the user to manually define their own alert types in `config.yml`?


Discussion / hints:
This is the regexp in question:

```
(?i)^<p>\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]
```

We HTML context here is _the start of a blockquote in a render hook_, so I suspect it would be more than specific enough if we made it slightly less specific...

/cc @jmooring 

Fix patch:
diff --git a/docs/content/en/render-hooks/blockquotes.md b/docs/content/en/render-hooks/blockquotes.md
index e0eda5c51c9..607514f043c 100755
--- a/docs/content/en/render-hooks/blockquotes.md
+++ b/docs/content/en/render-hooks/blockquotes.md
@@ -24,6 +24,20 @@ Blockquote render hook templates receive the following [context]:
 
 (`string`) Applicable when [`Type`](#type) is `alert`, this is the alert type converted to lowercase. See the [alerts](#alerts) section below.
 
+###### AlertTitle
+
+{{< new-in 0.134.0 >}}
+
+(`hstring.HTML`) Applicable when [`Type`](#type) is `alert` when using [Obsidian callouts] syntax, this is the alert title converted to HTML. 
+
+###### AlertSign
+
+{{< new-in 0.134.0 >}}
+
+(`string`) Applicable when [`Type`](#type) is `alert` when using [Obsidian callouts] syntax, this is one of "+", "-" or "" (empty string) to indicate the presence of a foldable sign.
+
+[Obsidian callouts]: https://help.obsidian.md/Editing+and+formatting/Callouts
+
 ###### Attributes
 
 (`map`) The [Markdown attributes], available if you configure your site as follows:
@@ -117,13 +131,13 @@ Also known as _callouts_ or _admonitions_, alerts are blockquotes used to emphas
 
 
 {{% note %}}
-This syntax is compatible with the GitHub Alert Markdown extension.
+This syntax is compatible with both the GitHub Alert Markdown extension and Obsidian's callout syntax. 
+But note that GitHub will not recognize callouts with one of Obsidian's extensions (e.g. callout title or the foldable sign).
 {{% /note %}}
 
-
 The first line of each alert is an alert designator consisting of an exclamation point followed by the alert type, wrapped within brackets.
 
-The blockquote render hook below renders a multilingual alert if an alert desginator is present, otherwise it renders a blockquote according to the CommonMark specification.
+The blockquote render hook below renders a multilingual alert if an alert designator is present, otherwise it renders a blockquote according to the CommonMark specification.
 
 {{< code file=layouts/_default/_markup/render-blockquote.html copy=true >}}
 {{ $emojis := dict
diff --git a/markup/converter/hooks/hooks.go b/markup/converter/hooks/hooks.go
index 96c165321ce..1fc513acb23 100644
--- a/markup/converter/hooks/hooks.go
+++ b/markup/converter/hooks/hooks.go
@@ -109,6 +109,16 @@ type BlockquoteContext interface {
 	// The GitHub alert type converted to lowercase, e.g. "note".
 	// Only set if Type is "alert".
 	AlertType() string
+
+	// The alert title.
+	// Currently only relevant for Obsidian alerts.
+	// GitHub does not suport alert titles and will not render alerts with titles.
+	AlertTitle() hstring.HTML
+
+	// The alert sign,  "+" or "-" or "" used to indicate folding.
+	// Currently only relevant for Obsidian alerts.
+	// GitHub does not suport alert signs and will not render alerts with signs.
+	AlertSign() string
 }
 
 type PositionerSourceTargetProvider interface {
diff --git a/markup/goldmark/blockquotes/blockquotes.go b/markup/goldmark/blockquotes/blockquotes.go
index bf1e848b807..f6d1a590ee0 100644
--- a/markup/goldmark/blockquotes/blockquotes.go
+++ b/markup/goldmark/blockquotes/blockquotes.go
@@ -74,8 +74,8 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
 	ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote)
 
 	typ := typeRegular
-	alertType := resolveGitHubAlert(string(text))
-	if alertType != "" {
+	alert := resolveBlockQuoteAlert(string(text))
+	if alert.typ != "" {
 		typ = typeAlert
 	}
 
@@ -94,7 +94,7 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
 	bqctx := &blockquoteContext{
 		BaseContext:      render.NewBaseContext(ctx, renderer, n, src, nil, ordinal),
 		typ:              typ,
-		alertType:        alertType,
+		alert:            alert,
 		text:             hstring.HTML(text),
 		AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral),
 	}
@@ -133,11 +133,9 @@ func (r *htmlRenderer) renderBlockquoteDefault(
 
 type blockquoteContext struct {
 	hooks.BaseContext
-
-	text      hstring.HTML
-	alertType string
-	typ       string
-
+	text  hstring.HTML
+	typ   string
+	alert blockQuoteAlert
 	*attributes.AttributesHolder
 }
 
@@ -146,25 +144,40 @@ func (c *blockquoteContext) Type() string {
 }
 
 func (c *blockquoteContext) AlertType() string {
-	return c.alertType
+	return c.alert.typ
+}
+
+func (c *blockquoteContext) AlertTitle() hstring.HTML {
+	return hstring.HTML(c.alert.title)
+}
+
+func (c *blockquoteContext) AlertSign() string {
+	return c.alert.sign
 }
 
 func (c *blockquoteContext) Text() hstring.HTML {
 	return c.text
 }
 
-// https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
-// Five types:
-// [!NOTE], [!TIP], [!WARNING], [!IMPORTANT], [!CAUTION]
-// Note that GitHub's implementation is case-insensitive.
-var gitHubAlertRe = regexp.MustCompile(`(?i)^<p>\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]`)
-
-// resolveGitHubAlert returns one of note, tip, warning, important or caution.
-// An empty string if no match.
-func resolveGitHubAlert(s string) string {
-	m := gitHubAlertRe.FindStringSubmatch(s)
-	if len(m) == 2 {
-		return strings.ToLower(m[1])
+var blockQuoteAlertRe = regexp.MustCompile(`^<p>\[!([a-zA-Z]+)\](-|\+)?[^\S\r\n]?([^\n]*)\n?`)
+
+func resolveBlockQuoteAlert(s string) blockQuoteAlert {
+	m := blockQuoteAlertRe.FindStringSubmatch(s)
+	if len(m) == 4 {
+		return blockQuoteAlert{
+			typ:   strings.ToLower(m[1]),
+			sign:  m[2],
+			title: m[3],
+		}
 	}
-	return ""
+
+	return blockQuoteAlert{}
+}
+
+// Blockquote alert syntax was introduced by GitHub, but is also used
+// by Obsidian which also support some extended attributes: More types, alert titles and a +/- sign for folding.
+type blockQuoteAlert struct {
+	typ   string
+	sign  string
+	title string
 }


Test patch:
diff --git a/markup/goldmark/blockquotes/blockquotes_integration_test.go b/markup/goldmark/blockquotes/blockquotes_integration_test.go
index f12600b4207..ae52b9ba790 100644
--- a/markup/goldmark/blockquotes/blockquotes_integration_test.go
+++ b/markup/goldmark/blockquotes/blockquotes_integration_test.go
@@ -109,3 +109,48 @@ Content: {{ .Content }}
 	b := hugolib.Test(t, files)
 	b.AssertFileContent("public/p1/index.html", "Content: <blockquote>\n</blockquote>\n")
 }
+
+func TestBlockquObsidianWithTitleAndSign(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- content/_index.md --
+---
+title: "Home"
+---
+
+> [!danger]
+> Do not approach or handle without protective gear.
+
+
+> [!tip] Callouts can have custom titles
+> Like this one.
+
+> [!tip] Title-only callout
+
+> [!faq]- Foldable negated callout
+> Yes! In a foldable callout, the contents are hidden when the callout is collapsed
+
+> [!faq]+ Foldable callout
+> Yes! In a foldable callout, the contents are hidden when the callout is collapsed
+
+-- layouts/index.html --
+{{ .Content }}
+-- layouts/_default/_markup/render-blockquote.html --
+AlertType: {{ .AlertType }}|
+AlertTitle: {{ .AlertTitle }}|
+AlertSign: {{ .AlertSign | safeHTML }}|
+Text: {{ .Text }}|
+	
+	`
+
+	b := hugolib.Test(t, files)
+	b.AssertFileContent("public/index.html",
+		"AlertType: tip|\nAlertTitle: Callouts can have custom titles|\nAlertSign: |",
+		"AlertType: tip|\nAlertTitle: Title-only callout</p>|\nAlertSign: |",
+		"AlertType: faq|\nAlertTitle: Foldable negated callout|\nAlertSign: -|\nText: <p>Yes!",
+		"AlertType: faq|\nAlertTitle: Foldable callout|\nAlertSign: +|",
+		"AlertType: danger|\nAlertTitle: |\nAlertSign: |\nText: <p>Do not approach or handle without protective gear.</p>\n|",
+	)
+}
diff --git a/markup/goldmark/blockquotes/blockquotes_test.go b/markup/goldmark/blockquotes/blockquotes_test.go
index 5b2680a0dbd..8b948af08c1 100644
--- a/markup/goldmark/blockquotes/blockquotes_test.go
+++ b/markup/goldmark/blockquotes/blockquotes_test.go
@@ -19,42 +19,50 @@ import (
 	qt "github.com/frankban/quicktest"
 )
 
-func TestResolveGitHubAlert(t *testing.T) {
+func TestResolveBlockQuoteAlert(t *testing.T) {
 	t.Parallel()
 
 	c := qt.New(t)
 
 	tests := []struct {
 		input    string
-		expected string
+		expected blockQuoteAlert
 	}{
 		{
 			input:    "[!NOTE]",
-			expected: "note",
+			expected: blockQuoteAlert{typ: "note"},
 		},
 		{
-			input:    "[!WARNING]",
-			expected: "warning",
+			input:    "[!FaQ]",
+			expected: blockQuoteAlert{typ: "faq"},
 		},
 		{
-			input:    "[!TIP]",
-			expected: "tip",
+			input:    "[!NOTE]+",
+			expected: blockQuoteAlert{typ: "note", sign: "+"},
 		},
 		{
-			input:    "[!IMPORTANT]",
-			expected: "important",
+			input:    "[!NOTE]-",
+			expected: blockQuoteAlert{typ: "note", sign: "-"},
 		},
 		{
-			input:    "[!CAUTION]",
-			expected: "caution",
+			input:    "[!NOTE] This is a note",
+			expected: blockQuoteAlert{typ: "note", title: "This is a note"},
 		},
 		{
-			input:    "[!FOO]",
-			expected: "",
+			input:    "[!NOTE]+ This is a note",
+			expected: blockQuoteAlert{typ: "note", sign: "+", title: "This is a note"},
+		},
+		{
+			input:    "[!NOTE]+ This is a title\nThis is not.",
+			expected: blockQuoteAlert{typ: "note", sign: "+", title: "This is a title"},
+		},
+		{
+			input:    "[!NOTE]\nThis is not.",
+			expected: blockQuoteAlert{typ: "note"},
 		},
 	}
 
-	for _, test := range tests {
-		c.Assert(resolveGitHubAlert("<p>"+test.input), qt.Equals, test.expected)
+	for i, test := range tests {
+		c.Assert(resolveBlockQuoteAlert("<p>"+test.input), qt.Equals, test.expected, qt.Commentf("Test %d", i))
 	}
 }