console: add admonitions to documentation markdown

This commit is contained in:
Vincent Bernat
2025-07-14 16:45:32 +02:00
parent 41ee631b37
commit 8ac89407fd
6 changed files with 490 additions and 38 deletions

190
console/admonitions.go Normal file
View File

@@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: 2025 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
package console
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
var (
admonitionRegexp = regexp.MustCompile(`^\[!(IMPORTANT|NOTE|TIP|WARNING|CAUTION)\]$`)
admonitionIcons = map[string]string{
"IMPORTANT": `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />`,
"NOTE": `<path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />`,
"TIP": `<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />`,
"WARNING": `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />`,
"CAUTION": `<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.25-8.25-3.286Zm0 13.036h.008v.008H12v-.008Z" />`,
}
kindAdmonition = ast.NewNodeKind("Admonition")
)
// admonitionNode represents an admonition (kind of like a blockquote with an icon)
type admonitionNode struct {
ast.BaseBlock
AdmonitionType string
}
// newAdmonitionNode creates a new admonition node
func newAdmonitionNode(admonitionType string) *admonitionNode {
return &admonitionNode{
AdmonitionType: admonitionType,
}
}
// Dump implements ast.Node
func (n *admonitionNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, nil, nil)
}
// Kind implements ast.Node
func (n *admonitionNode) Kind() ast.NodeKind {
return kindAdmonition
}
// admonitionTransformer converts blockquotes to admonitions.
type admonitionTransformer struct{}
// Transform transforms the AST to turns blockquotes into admonitions.
func (t *admonitionTransformer) Transform(node *ast.Document, reader text.Reader, _ parser.Context) {
walker := func(n ast.Node) ast.WalkStatus {
blockquote, ok := n.(*ast.Blockquote)
if !ok {
return ast.WalkContinue
}
// Check if the first child is a paragraph with admonition syntax
firstChild := blockquote.FirstChild()
if firstChild == nil {
return ast.WalkSkipChildren
}
paragraph, ok := firstChild.(*ast.Paragraph)
if !ok {
return ast.WalkSkipChildren
}
lines := paragraph.Lines()
if lines.Len() == 0 {
return ast.WalkSkipChildren
}
firstLine := lines.At(0)
lineContent := bytes.TrimSpace(firstLine.Value(reader.Source()))
matches := admonitionRegexp.FindSubmatch(lineContent)
if matches == nil {
return ast.WalkSkipChildren
}
// Recreate the paragraph with the admonition marker removed. We assume it was alone on its line.
newSegments := text.NewSegments()
for i := 1; i < lines.Len(); i++ {
line := lines.At(i)
newSegments.Append(line)
}
paragraph.SetLines(newSegments)
for child := paragraph.FirstChild(); child != nil; {
next := child.NextSibling()
if s, ok := child.(*ast.Text); ok && s.Segment.Stop <= firstLine.Stop {
paragraph.RemoveChild(paragraph, child)
}
child = next
}
// Move all children from blockquote to admonition and replace the
// blockquote with admonition.
admonitionType := string(matches[1])
admonitionNode := newAdmonitionNode(admonitionType)
admonitionNode.AppendChild(admonitionNode, paragraph)
for child := blockquote.FirstChild(); child != nil; {
next := child.NextSibling()
blockquote.RemoveChild(blockquote, child)
admonitionNode.AppendChild(admonitionNode, child)
child = next
}
parent := blockquote.Parent()
parent.ReplaceChild(parent, blockquote, admonitionNode)
return ast.WalkSkipChildren
}
var walk func(ast.Node) ast.WalkStatus
// This is almost like ast.Walk(), except it keeps a reference to the next
// sibling in case the current element gets removed. We can switch back to
// ast.Walk() once https://github.com/yuin/goldmark/pull/523 is merged.
walk = func(n ast.Node) ast.WalkStatus {
status := walker(n)
if status == ast.WalkStop {
return status
}
if status != ast.WalkSkipChildren {
for c := n.FirstChild(); c != nil; {
next := c.NextSibling()
if st := walk(c); st == ast.WalkStop {
return st
}
c = next
}
}
return ast.WalkContinue
}
walk(node)
}
// admonitionRenderer renders admonitions to HTML
type admonitionRenderer struct{}
// RegisterFuncs implements renderer.NodeRenderer
func (r *admonitionRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindAdmonition, r.renderAdmonition)
}
func (r *admonitionRenderer) renderAdmonition(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*admonitionNode)
if entering {
admonitionClass := "admonition admonition-" + strings.ToLower(n.AdmonitionType)
admonitionIcon := admonitionIcons[n.AdmonitionType]
_, _ = w.WriteString(`<div class="` + admonitionClass + `" dir="auto">`)
_, _ = w.WriteString(`<p class="admonition-title" dir="auto">`)
_, _ = w.WriteString(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">`)
_, _ = w.WriteString(admonitionIcon)
_, _ = w.WriteString(`</svg>`)
title := strings.ToLower(n.AdmonitionType)
if len(title) > 0 {
title = strings.ToUpper(title[:1]) + title[1:]
}
_, _ = w.WriteString(title)
_, _ = w.WriteString(`</p>`)
} else {
_, _ = w.WriteString(`</div>`)
}
return ast.WalkContinue, nil
}
// admonitionExtension extends goldmark with admonitions
type admonitionExtension struct{}
// Extend implements goldmark.Extender
func (e *admonitionExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithASTTransformers(
util.Prioritized(&admonitionTransformer{}, 500),
),
)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&admonitionRenderer{}, 500),
),
)
}

209
console/admonitions_test.go Normal file
View File

@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2025 Free Mobile
// SPDX-License-Identifier: AGPL-3.0-only
package console
import (
"bytes"
"strings"
"testing"
"github.com/yuin/goldmark"
)
func TestAdmonition(t *testing.T) {
md := goldmark.New(
goldmark.WithExtensions(&admonitionExtension{}),
)
tests := []struct {
name string
input string
contains []string
notContains []string
}{
{
name: "IMPORTANT admonition with content",
input: `> [!IMPORTANT]
> This is important information.
> It spans multiple lines.`,
contains: []string{
`class="admonition admonition-important"`,
`Important</p>`,
`<p>This is important information.`,
`It spans multiple lines.</p>`,
},
},
{
name: "NOTE admonition",
input: `> [!NOTE]
> This is a note.`,
contains: []string{
`class="admonition admonition-note"`,
`Note</p>`,
`<p>This is a note.</p>`,
},
},
{
name: "TIP admonition",
input: `> [!TIP]
> This is a tip.`,
contains: []string{
`class="admonition admonition-tip"`,
`Tip</p>`,
`<p>This is a tip.</p>`,
},
},
{
name: "WARNING admonition",
input: `> [!WARNING]
> This is a warning.`,
contains: []string{
`class="admonition admonition-warning"`,
`Warning</p>`,
`<p>This is a warning.</p>`,
},
},
{
name: "CAUTION admonition",
input: `> [!CAUTION]
> This is a caution.`,
contains: []string{
`class="admonition admonition-caution"`,
`Caution</p>`,
`<p>This is a caution.</p>`,
},
},
{
name: "CAUTION and TIP adominitions",
input: `
This is just a text.
> [!CAUTION]
> This is a caution.
> [!TIP]
> This is a tip.
`,
contains: []string{
`class="admonition admonition-caution"`,
`Caution</p>`,
`<p>This is a caution.</p>`,
`class="admonition admonition-tip"`,
`Tip</p>`,
`<p>This is a tip.</p>`,
},
},
{
name: "Regular blockquote should not be affected",
input: `> This is a regular blockquote.
> It should not be styled as an admonition.`,
contains: []string{
`<blockquote>`,
`<p>This is a regular blockquote.`,
},
},
{
name: "Links inside admonition",
input: `> [!NOTE]
> Check the [configuration guide](config.md) for more details.`,
contains: []string{
`class="admonition admonition-note"`,
`Note</p>`,
`<p>Check the <a href="config.md">configuration guide</a> for more details.</p>`,
},
notContains: []string{
`[!NOTE]`,
`<blockquote>`,
},
},
{
name: "Emphasis inside admonition",
input: `> [!WARNING]
> This is *very* important and **must** be done.`,
contains: []string{
`class="admonition admonition-warning"`,
`Warning</p>`,
`<p>This is <em>very</em> important and <strong>must</strong> be done.</p>`,
},
notContains: []string{
`[!WARNING]`,
`<blockquote>`,
},
},
{
name: "Code inside admonition",
input: `> [!TIP]
> Use the` + " `docker compose up` " + `command to start the services.`,
contains: []string{
`class="admonition admonition-tip"`,
`Tip</p>`,
`<p>Use the <code>docker compose up</code> command to start the services.</p>`,
},
notContains: []string{
`[!TIP]`,
`<blockquote>`,
},
},
{
name: "Multiple markdown features",
input: `> [!IMPORTANT]
> Read the **[documentation](docs.md)** carefully.
> The` + " `config.yaml` " + `file is *essential*.`,
contains: []string{
`class="admonition admonition-important"`,
`Important</p>`,
`<p>Read the <strong><a href="docs.md">documentation</a></strong> carefully.`,
`The <code>config.yaml</code> file is <em>essential</em>.</p>`,
},
notContains: []string{
`[!IMPORTANT]`,
`<blockquote>`,
},
},
{
name: "List inside admonition",
input: `> [!NOTE]
> Follow these steps:
> 1. First step
> 2. Second step with **bold** text`,
contains: []string{
`class="admonition admonition-note"`,
`Note</p>`,
`<p>Follow these steps:</p>`,
`<ol>`,
`<li>First step</li>`,
`<li>Second step with <strong>bold</strong> text</li>`,
`</ol>`,
},
notContains: []string{
`[!NOTE]`,
`<blockquote>`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
if err := md.Convert([]byte(tt.input), &buf); err != nil {
t.Errorf("Convert() error:\n%+v", err)
return
}
html := buf.String()
for _, expected := range tt.contains {
if !strings.Contains(html, expected) {
t.Errorf("Convert() should have %q:\n%s", expected, html)
}
}
for _, notExpected := range tt.notContains {
if strings.Contains(html, notExpected) {
t.Errorf("Convert() should not have %q:\n%s", notExpected, html)
}
}
})
}
}

View File

@@ -33,26 +33,26 @@ explore the features.
To connect your own network devices: To connect your own network devices:
1. **Disable demo data**: 1. Disable demo data:
- Remove the reference to `docker-compose-demo.yml` from `.env` - Remove the reference to `docker-compose-demo.yml` from `.env`
- Comment out the last line in `akvorado.yaml` - Comment out the last line in `akvorado.yaml`
1. **Customize the configuration** in `akvorado.yaml`: 1. Customize the configuration in `akvorado.yaml`:
- Set SNMP communities for your devices in `outlet``metadata``provider``communities` - Set SNMP communities for your devices in `outlet``metadata``provider``communities`
- Configure interface classification rules in `outlet``core``interface-classifiers` - Configure interface classification rules in `outlet``core``interface-classifiers`
1. **Configure your routers/switches** to send flows to *Akvorado*: 1. Configure your routers/switches to send flows to *Akvorado*:
- NetFlow/IPFIX: port 2055 - NetFlow/IPFIX: port 2055
- sFlow: port 6343 - sFlow: port 6343
1. **Restart all containers** 1. Restart all containers:
- `docker compose down --volumes` - `docker compose down --volumes`
- `docker compose up -d` - `docker compose up -d`
> [!IMPORTANT] > [!TIP]
> Interface classification is essential for the web interface to work properly. > Interface classification is essential for the web interface to work properly.
> Without it, you won't see data in the dashboard widgets or visualization tab. > Without it, you won't see data in the dashboard widgets or visualization tab.
> See the [configuration guide](02-configuration.md) for details. > See the [configuration guide](02-configuration.md#classification) for details.
### Need help? ### Need help?
@@ -65,6 +65,11 @@ You can get all the expanded configuration (with default values) with
`docker compose exec akvorado-orchestrator akvorado orchestrator `docker compose exec akvorado-orchestrator akvorado orchestrator
--check --dump /etc/akvorado/akvorado.yaml`. --check --dump /etc/akvorado/akvorado.yaml`.
> [!IMPORTANT]
> Please, do not open an issue or start a discussion unless you have read the
> various chapters of the documentation, notably the [troubleshooting
> guide](05-troubleshooting.md).
## Big picture ## Big picture
![General design](design.svg) ![General design](design.svg)

View File

@@ -488,40 +488,15 @@ The following configuration keys are accepted:
component. If multiple sources are provided, the value of the first source component. If multiple sources are provided, the value of the first source
providing a non-default route is taken. The default value is `flow` and `routing`. providing a non-default route is taken. The default value is `flow` and `routing`.
Classifier rules are written using [Expr][]. #### Classification
Exporter classifiers gets the classifier IP address and its hostname. Classifier rules are written in a language called [Expr][].
If they can make a decision, they should invoke one of the
`Classify()` functions with the target element as an argument. Once
classification is done for an element, it cannot be changed by a
subsequent rule. All strings are normalized (lower case, special chars
removed).
- `Exporter.IP` for the exporter IP address Interface classifiers gets exporter and interface-related information as input.
- `Exporter.Name` for the exporter name If they can make a decision, they should invoke one of the `Classify()`
- `ClassifyGroup()` to classify the exporter to a group functions with the target element as an argument. Once classification is done
- `ClassifyRole()` to classify the exporter for a role (`edge`, `core`) for an element, it cannot be changed by a subsequent rule. All strings are
- `ClassifySite()` to classify the exporter to a site (`paris`, `berlin`, `newyork`) normalized (lower case, special chars removed).
- `ClassifyRegion()` to classify the exporter to a region (`france`, `italy`, `caraibes`)
- `ClassifyTenant()` to classify the exporter to a tenant (`team-a`, `team-b`)
- `Reject()` to reject the flow
- `Format()` to format a string: `Format("name: %s", Exporter.Name)`
As a compatibility `Classify()` is an alias for `ClassifyGroup()`.
Here is an example, assuming routers are named
`th2-ncs55a1-1.example.fr` or `milan-ncs5k8-2.example.it`:
```yaml
exporter-classifiers:
- ClassifySiteRegex(Exporter.Name, "^([^-]+)-", "$1")
- Exporter.Name endsWith ".it" && ClassifyRegion("italy")
- Exporter.Name matches "^(washington|newyork).*" && ClassifyRegion("usa")
- Exporter.Name endsWith ".fr" && ClassifyRegion("france")
```
Interface classifiers gets the following information and, like exporter
classifiers, should invoke one of the `Classify()` functions to make a
decision:
- `Exporter.IP` for the exporter IP address - `Exporter.IP` for the exporter IP address
- `Exporter.Name` for the exporter name - `Exporter.Name` for the exporter name
@@ -572,6 +547,38 @@ interface-classifiers:
- ClassifyInternal() - ClassifyInternal()
``` ```
The first rule says “extract the connectivity (transit, pni, ppni or ix) from
the interface description, and if successful, use the second part of the
description as the provider, and if successful, considers the interface as an
external one”. The second rule says “if an interface was not classified as
external or internal, consider it as an internal one.”
Exporter classifiers gets the classifier IP address and its hostname. Like the
interface classifiers, they should invoke one of the `Classify()` functions to
make a decision:
- `Exporter.IP` for the exporter IP address
- `Exporter.Name` for the exporter name
- `ClassifyGroup()` to classify the exporter to a group
- `ClassifyRole()` to classify the exporter for a role (`edge`, `core`)
- `ClassifySite()` to classify the exporter to a site (`paris`, `berlin`, `newyork`)
- `ClassifyRegion()` to classify the exporter to a region (`france`, `italy`, `caraibes`)
- `ClassifyTenant()` to classify the exporter to a tenant (`team-a`, `team-b`)
- `Reject()` to reject the flow
- `Format()` to format a string: `Format("name: %s", Exporter.Name)`
As a compatibility `Classify()` is an alias for `ClassifyGroup()`.
Here is an example, assuming routers are named
`th2-ncs55a1-1.example.fr` or `milan-ncs5k8-2.example.it`:
```yaml
exporter-classifiers:
- ClassifySiteRegex(Exporter.Name, "^([^-]+)-", "$1")
- Exporter.Name endsWith ".it" && ClassifyRegion("italy")
- Exporter.Name matches "^(washington|newyork).*" && ClassifyRegion("usa")
- Exporter.Name endsWith ".fr" && ClassifyRegion("france")
```
[expr]: https://expr-lang.org/docs/language-definition [expr]: https://expr-lang.org/docs/language-definition
[from Go]: https://github.com/google/re2/wiki/Syntax [from Go]: https://github.com/google/re2/wiki/Syntax

View File

@@ -110,6 +110,7 @@ func (c *Component) docsHandlerFunc(gc *gin.Context) {
highlighting.NewHighlighting( highlighting.NewHighlighting(
highlighting.WithCustomStyle(draculaStyle), highlighting.WithCustomStyle(draculaStyle),
), ),
&admonitionExtension{},
), ),
goldmark.WithParserOptions( goldmark.WithParserOptions(
parser.WithAutoHeadingID(), parser.WithAutoHeadingID(),

View File

@@ -34,3 +34,43 @@
} }
} }
} }
/* Admonitions */
.prose .admonition {
@apply my-4 rounded-md border-l-4 p-4;
.admonition-title {
@apply mt-0 flex items-center font-bold;
svg {
@apply mr-2 h-4 w-4;
}
}
p:last-child {
@apply mb-0;
}
}
.prose .admonition-important {
@apply border-purple-500 bg-purple-50 text-purple-800 dark:bg-purple-900/20 dark:text-purple-200;
}
.prose .admonition-note {
@apply border-blue-500 bg-blue-50 text-blue-800 dark:bg-blue-900/20 dark:text-blue-200;
}
.prose .admonition-tip {
@apply border-green-500 bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200;
}
.prose .admonition-warning {
@apply border-yellow-500 bg-yellow-50 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200;
svg {
transform: translateY(2px);
}
}
.prose .admonition-caution {
@apply border-red-500 bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200;
}