mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-11 22:14:02 +01:00
console: add admonitions to documentation markdown
This commit is contained in:
190
console/admonitions.go
Normal file
190
console/admonitions.go
Normal 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
209
console/admonitions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,26 +33,26 @@ explore the features.
|
||||
|
||||
To connect your own network devices:
|
||||
|
||||
1. **Disable demo data**:
|
||||
1. Disable demo data:
|
||||
- Remove the reference to `docker-compose-demo.yml` from `.env`
|
||||
- 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`
|
||||
- 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
|
||||
- sFlow: port 6343
|
||||
|
||||
1. **Restart all containers**
|
||||
1. Restart all containers:
|
||||
- `docker compose down --volumes`
|
||||
- `docker compose up -d`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [!TIP]
|
||||
> 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.
|
||||
> See the [configuration guide](02-configuration.md) for details.
|
||||
> See the [configuration guide](02-configuration.md#classification) for details.
|
||||
|
||||
### Need help?
|
||||
|
||||
@@ -65,6 +65,11 @@ You can get all the expanded configuration (with default values) with
|
||||
`docker compose exec akvorado-orchestrator akvorado orchestrator
|
||||
--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
|
||||
|
||||

|
||||
|
||||
@@ -488,40 +488,15 @@ The following configuration keys are accepted:
|
||||
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`.
|
||||
|
||||
Classifier rules are written using [Expr][].
|
||||
#### Classification
|
||||
|
||||
Exporter classifiers gets the classifier IP address and its hostname.
|
||||
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).
|
||||
Classifier rules are written in a language called [Expr][].
|
||||
|
||||
- `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")
|
||||
```
|
||||
|
||||
Interface classifiers gets the following information and, like exporter
|
||||
classifiers, should invoke one of the `Classify()` functions to make a
|
||||
decision:
|
||||
Interface classifiers gets exporter and interface-related information as input.
|
||||
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
|
||||
- `Exporter.Name` for the exporter name
|
||||
@@ -572,6 +547,38 @@ interface-classifiers:
|
||||
- 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
|
||||
[from Go]: https://github.com/google/re2/wiki/Syntax
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ func (c *Component) docsHandlerFunc(gc *gin.Context) {
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithCustomStyle(draculaStyle),
|
||||
),
|
||||
&admonitionExtension{},
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user