Add support for Obsidian type blockquote alerts

* Make the alert type parsing more flexible to support more types
* Add `AlertTitle` and `AlertSign` (for folding)

Note that GitHub will not render callouts with alert title/sign.

See https://help.obsidian.md/Editing+and+formatting/Callouts

Closes #12805
Closes #12801
This commit is contained in:
Bjørn Erik Pedersen 2024-09-01 12:00:13 +02:00
parent 0c453420e6
commit e651d29801
5 changed files with 129 additions and 39 deletions

View file

@ -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. (`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 ###### Attributes
(`map`) The [Markdown attributes], available if you configure your site as follows: (`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 %}} {{% 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 %}} {{% /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 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 >}} {{< code file=layouts/_default/_markup/render-blockquote.html copy=true >}}
{{ $emojis := dict {{ $emojis := dict

View file

@ -109,6 +109,16 @@ type BlockquoteContext interface {
// The GitHub alert type converted to lowercase, e.g. "note". // The GitHub alert type converted to lowercase, e.g. "note".
// Only set if Type is "alert". // Only set if Type is "alert".
AlertType() string 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 { type PositionerSourceTargetProvider interface {

View file

@ -74,8 +74,8 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote) ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote)
typ := typeRegular typ := typeRegular
alertType := resolveGitHubAlert(string(text)) alert := resolveBlockQuoteAlert(string(text))
if alertType != "" { if alert.typ != "" {
typ = typeAlert typ = typeAlert
} }
@ -94,7 +94,7 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
bqctx := &blockquoteContext{ bqctx := &blockquoteContext{
BaseContext: render.NewBaseContext(ctx, renderer, n, src, nil, ordinal), BaseContext: render.NewBaseContext(ctx, renderer, n, src, nil, ordinal),
typ: typ, typ: typ,
alertType: alertType, alert: alert,
text: hstring.HTML(text), text: hstring.HTML(text),
AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral), AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral),
} }
@ -133,11 +133,9 @@ func (r *htmlRenderer) renderBlockquoteDefault(
type blockquoteContext struct { type blockquoteContext struct {
hooks.BaseContext hooks.BaseContext
text hstring.HTML text hstring.HTML
alertType string
typ string typ string
alert blockQuoteAlert
*attributes.AttributesHolder *attributes.AttributesHolder
} }
@ -146,25 +144,40 @@ func (c *blockquoteContext) Type() string {
} }
func (c *blockquoteContext) AlertType() 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 { func (c *blockquoteContext) Text() hstring.HTML {
return c.text 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 var blockQuoteAlertRe = regexp.MustCompile(`^<p>\[!([a-zA-Z]+)\](-|\+)?[^\S\r\n]?([^\n]*)\n?`)
// 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. func resolveBlockQuoteAlert(s string) blockQuoteAlert {
// An empty string if no match. m := blockQuoteAlertRe.FindStringSubmatch(s)
func resolveGitHubAlert(s string) string { if len(m) == 4 {
m := gitHubAlertRe.FindStringSubmatch(s) return blockQuoteAlert{
if len(m) == 2 { typ: strings.ToLower(m[1]),
return 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
} }

View file

@ -109,3 +109,48 @@ Content: {{ .Content }}
b := hugolib.Test(t, files) b := hugolib.Test(t, files)
b.AssertFileContent("public/p1/index.html", "Content: <blockquote>\n</blockquote>\n") 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|",
)
}

View file

@ -19,42 +19,50 @@ import (
qt "github.com/frankban/quicktest" qt "github.com/frankban/quicktest"
) )
func TestResolveGitHubAlert(t *testing.T) { func TestResolveBlockQuoteAlert(t *testing.T) {
t.Parallel() t.Parallel()
c := qt.New(t) c := qt.New(t)
tests := []struct { tests := []struct {
input string input string
expected string expected blockQuoteAlert
}{ }{
{ {
input: "[!NOTE]", input: "[!NOTE]",
expected: "note", expected: blockQuoteAlert{typ: "note"},
}, },
{ {
input: "[!WARNING]", input: "[!FaQ]",
expected: "warning", expected: blockQuoteAlert{typ: "faq"},
}, },
{ {
input: "[!TIP]", input: "[!NOTE]+",
expected: "tip", expected: blockQuoteAlert{typ: "note", sign: "+"},
}, },
{ {
input: "[!IMPORTANT]", input: "[!NOTE]-",
expected: "important", expected: blockQuoteAlert{typ: "note", sign: "-"},
}, },
{ {
input: "[!CAUTION]", input: "[!NOTE] This is a note",
expected: "caution", expected: blockQuoteAlert{typ: "note", title: "This is a note"},
}, },
{ {
input: "[!FOO]", input: "[!NOTE]+ This is a note",
expected: "", 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 { for i, test := range tests {
c.Assert(resolveGitHubAlert("<p>"+test.input), qt.Equals, test.expected) c.Assert(resolveBlockQuoteAlert("<p>"+test.input), qt.Equals, test.expected, qt.Commentf("Test %d", i))
} }
} }