add icon validation to mcp and assistant tools

This commit is contained in:
david 2026-10-11 14:13:28 +03:00
parent a4b374b0cf
commit d0f447b361
13 changed files with 1096 additions and 22 deletions

View File

@ -9,12 +9,16 @@ on:
- main
paths:
- web/**
# the icon list test compares against core's copy.
- server/core/internal/assistant/icon/icons.json
- .github/workflows/qa-web.yml
pull_request:
branches:
- main
paths:
- web/**
# the icon list test compares against core's copy.
- server/core/internal/assistant/icon/icons.json
- .github/workflows/qa-web.yml
# a newer push supersedes the still-running workflow of the stale commit

View File

@ -0,0 +1,155 @@
// Package icon checks icon ids against the ones the editor's picker
// offers.
package icon
import (
"cmp"
_ "embed"
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"unicode"
)
const (
// _maxIDLength is the longest icon id Validate looks at. Every
// offered id is far shorter, and scoring a long one is slow.
_maxIDLength = 128
// _maxSuggestions is the number of close icons an error names.
_maxSuggestions = 8
// _exactScore is what a query word scores against an equal word.
_exactScore = 2
// _partialScore is what a query word scores against a word that
// holds it or that it holds.
_partialScore = 1
// _minPartialWord is the shortest word that matches as part of
// another. Shorter ones, such as net or js, are inside too many names.
_minPartialWord = 4
)
// _iconsJSON is the picker's icon list, in the picker's order. The web
// test in web/app/utils/icon.test.ts keeps it equal to the list there.
//
//go:embed icons.json
var _iconsJSON []byte
// _styleSuffix matches the style and variant suffixes an icon id ends
// with, such as -fill, -24-filled or -2.
var _styleSuffix = regexp.MustCompile(`(-(fill|line|filled|outline|solid|\d+))+$`)
// entry is one icon the picker offers.
type entry struct {
// ID specifies the Iconify id, such as mingcute:rocket-fill.
ID string `json:"id"`
// Name specifies the words the picker shows and searches.
Name string `json:"name"`
}
// words returns the lowercase words of the icon's name and id.
func (e entry) words() []string {
return slices.Concat(words(e.Name), extractWords(e.ID))
}
// Validate returns nil when id is an icon the picker offers, or an
// error naming the offered icons whose names are closest to it.
func Validate(id string) error {
if len(id) > _maxIDLength {
return fmt.Errorf("icon is %d bytes, but an icon id is at most %d; use an id from the icon picker", len(id), _maxIDLength)
}
var entries []entry
if err := json.Unmarshal(_iconsJSON, &entries); err != nil {
// NOCOV: the embedded file is valid, or every Validate test fails.
return fmt.Errorf("parsing the embedded icon list: %w", err)
}
if slices.ContainsFunc(entries, func(e entry) bool { return e.ID == id }) {
return nil
}
closest := suggest(entries, id)
if len(closest) == 0 {
return fmt.Errorf("icon %q is not available and no available icon has a similar name; try a plainer word, such as mingcute:document-2-fill", id)
}
return fmt.Errorf("icon %q is not available; use one of %s", id, strings.Join(closest, ", "))
}
// suggest returns the ids of the icons whose words best match the words
// of id, best first, ties in list order.
func suggest(entries []entry, id string) []string {
query := extractWords(id)
type scored struct {
id string
score int
}
var hits []scored
for _, e := range entries {
iconWords := e.words()
total := 0
for _, q := range query {
best := 0
for _, w := range iconWords {
best = max(best, wordScore(q, w))
}
total += best
}
if total > 0 {
hits = append(hits, scored{e.ID, total})
}
}
slices.SortStableFunc(hits, func(a, b scored) int { return cmp.Compare(b.score, a.score) })
out := make([]string, 0, _maxSuggestions)
for _, h := range hits[:min(len(hits), _maxSuggestions)] {
out = append(out, h.id)
}
return out
}
// wordScore returns the score of one word against another: exact when
// equal, partial when one holds the other and both are long enough, and
// 0 otherwise.
func wordScore(a, b string) int {
switch {
case a == b:
return _exactScore
case len(a) >= _minPartialWord && len(b) >= _minPartialWord && (strings.Contains(a, b) || strings.Contains(b, a)):
return _partialScore
default:
return 0
}
}
// extractWords returns the lowercase words of an icon id without its
// collection and style suffix.
func extractWords(id string) []string {
if _, rest, ok := strings.Cut(id, ":"); ok {
id = rest
}
return words(_styleSuffix.ReplaceAllString(strings.ToLower(id), ""))
}
// words splits s into lowercase words of letters and digits.
func words(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}

View File

@ -0,0 +1,156 @@
package icon
import (
"errors"
"slices"
"strings"
"testing"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func Test_entry_words(t *testing.T) {
t.Parallel()
e := entry{ID: "simple-icons:amazonwebservices", Name: "aws"}
assert.Equal(t, []string{"aws", "amazonwebservices"}, e.words())
}
func Test_Validate(t *testing.T) {
t.Parallel()
cc := map[string]struct {
ID string
Err error
}{
"Offered icon": {ID: "mingcute:rocket-fill"},
"Id over the length limit": {
ID: strings.Repeat("a-", 65),
Err: errors.New("icon is 130 bytes, but an icon id is at most 128; use an id from the icon picker"),
},
"Offered icon from another collection": {ID: "simple-icons:kubernetes"},
"Unknown icon with close ones": {
ID: "lucide:rocket",
Err: errors.New(`icon "lucide:rocket" is not available; use one of mingcute:rocket-fill, mingcute:rocket-2-fill`),
},
"Unknown icon with nothing close": {
ID: "mingcute:deploy-fill",
Err: errors.New(`icon "mingcute:deploy-fill" is not available and no available icon has a similar name; try a plainer word, such as mingcute:document-2-fill`),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, Validate(c.ID))
})
}
}
func Test_suggest(t *testing.T) {
t.Parallel()
entries := []entry{
{ID: "mingcute:book-fill", Name: "book"},
{ID: "mingcute:folder-open-fill", Name: "folder open"},
{ID: "mingcute:book-open-fill", Name: "book open"},
{ID: "devicon-plain:postgresql", Name: "postgresql"},
{ID: "mingcute:cube-fill", Name: "cube"},
}
cc := map[string]struct {
Entries []entry
ID string
Result []string
}{
"Exact words rank first and ties keep list order": {
Entries: entries,
ID: "lucide:book-open",
Result: []string{"mingcute:book-open-fill", "mingcute:book-fill", "mingcute:folder-open-fill"},
},
"Word inside a name": {
Entries: entries,
ID: "mingcute:postgres-fill",
Result: []string{"devicon-plain:postgresql"},
},
"Nothing close": {
Entries: entries,
ID: "mingcute:deploy-fill",
Result: []string{},
},
"Result stops at the cap": {
Entries: slices.Repeat([]entry{{ID: "x:book", Name: "book"}}, _maxSuggestions+2),
ID: "book",
Result: slices.Repeat([]string{"x:book"}, _maxSuggestions),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, suggest(c.Entries, c.ID))
})
}
}
func Test_wordScore(t *testing.T) {
t.Parallel()
cc := map[string]struct {
A string
B string
Result int
}{
"Equal words": {A: "c", B: "c", Result: 2},
"One holds the other": {A: "postgres", B: "postgresql", Result: 1},
"Other holds the one": {A: "postgresql", B: "postgres", Result: 1},
"Short word inside": {A: "net", B: "kubernetes"},
"Unrelated words": {A: "deploy", B: "rocket"},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, wordScore(c.A, c.B))
})
}
}
func Test_extractWords(t *testing.T) {
t.Parallel()
cc := map[string]struct {
ID string
Result []string
}{
"Collection and fill": {ID: "mingcute:shield-check-fill", Result: []string{"shield", "check"}},
"Fluent size and style": {ID: "fluent:cloud-sync-24-filled", Result: []string{"cloud", "sync"}},
"Variant number": {ID: "mingcute:rocket-2-fill", Result: []string{"rocket"}},
"No collection": {ID: "Rocket", Result: []string{"rocket"}},
"Underscores and outline": {ID: "x:file_code-outline", Result: []string{"file", "code"}},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, extractWords(c.ID))
})
}
}
func Test_words(t *testing.T) {
t.Parallel()
assert.Equal(t, []string{"let", "s", "encrypt", "node", "js"}, words("Let's encrypt node.js"))
}

View File

@ -0,0 +1,695 @@
[
{ "id": "mingcute:document-2-fill", "name": "document" },
{ "id": "mingcute:document-3-fill", "name": "document" },
{ "id": "mingcute:documents-fill", "name": "documents" },
{ "id": "mingcute:doc-fill", "name": "doc" },
{ "id": "mingcute:file-code-fill", "name": "file code" },
{ "id": "mingcute:file-certificate-fill", "name": "file certificate" },
{ "id": "mingcute:file-check-fill", "name": "file check" },
{ "id": "mingcute:file-info-fill", "name": "file info" },
{ "id": "mingcute:file-locked-fill", "name": "file locked" },
{ "id": "mingcute:file-search-fill", "name": "file search" },
{ "id": "mingcute:file-warning-fill", "name": "file warning" },
{ "id": "mingcute:file-zip-fill", "name": "file zip" },
{ "id": "mingcute:pdf-fill", "name": "pdf" },
{ "id": "mingcute:markdown-fill", "name": "markdown" },
{ "id": "mingcute:xls-fill", "name": "xls" },
{ "id": "mingcute:folder-fill", "name": "folder" },
{ "id": "mingcute:folder-open-fill", "name": "folder open" },
{ "id": "mingcute:folders-fill", "name": "folders" },
{ "id": "mingcute:folder-locked-fill", "name": "folder locked" },
{ "id": "mingcute:book-fill", "name": "book" },
{ "id": "mingcute:book-2-fill", "name": "book" },
{ "id": "mingcute:book-3-fill", "name": "book" },
{ "id": "mingcute:book-6-fill", "name": "book" },
{ "id": "mingcute:notebook-fill", "name": "notebook" },
{ "id": "mingcute:notebook-2-fill", "name": "notebook" },
{ "id": "mingcute:diary-fill", "name": "diary" },
{ "id": "mingcute:news-fill", "name": "news" },
{ "id": "mingcute:paper-fill", "name": "paper" },
{ "id": "fluent:script-24-filled", "name": "script" },
{ "id": "mingcute:clipboard-fill", "name": "clipboard" },
{ "id": "mingcute:list-check-fill", "name": "list check" },
{ "id": "mingcute:list-ordered-fill", "name": "list ordered" },
{ "id": "ph:kanban-fill", "name": "kanban" },
{ "id": "fluent:steps-24-filled", "name": "steps" },
{ "id": "mingcute:task-fill", "name": "task" },
{ "id": "mingcute:task-2-fill", "name": "task" },
{ "id": "mingcute:to-do-fill", "name": "to do" },
{ "id": "mingcute:report-fill", "name": "report" },
{ "id": "mingcute:report-forms-fill", "name": "report forms" },
{ "id": "mingcute:presentation-1-fill", "name": "presentation" },
{ "id": "mingcute:presentation-2-fill", "name": "presentation" },
{ "id": "mingcute:whiteboard-fill", "name": "whiteboard" },
{ "id": "mingcute:black-board-fill", "name": "black board" },
{ "id": "mingcute:mind-map-fill", "name": "mind map" },
{ "id": "mingcute:sitemap-fill", "name": "sitemap" },
{ "id": "mingcute:timeline-fill", "name": "timeline" },
{ "id": "mingcute:version-fill", "name": "version" },
{ "id": "mingcute:quill-pen-fill", "name": "quill pen" },
{ "id": "mingcute:pen-fill", "name": "pen" },
{ "id": "mingcute:pencil-fill", "name": "pencil" },
{ "id": "mingcute:edit-fill", "name": "edit" },
{ "id": "mingcute:mark-pen-fill", "name": "mark pen" },
{ "id": "mingcute:signature-fill", "name": "signature" },
{ "id": "mingcute:certificate-fill", "name": "certificate" },
{ "id": "mingcute:seal-fill", "name": "seal" },
{ "id": "mingcute:quote-left-fill", "name": "quote left" },
{ "id": "mingcute:translate-fill", "name": "translate" },
{ "id": "mingcute:pin-fill", "name": "pin" },
{ "id": "mingcute:inbox-fill", "name": "inbox" },
{ "id": "mingcute:archive-fill", "name": "archive" },
{ "id": "mingcute:package-fill", "name": "package" },
{ "id": "mingcute:package-2-fill", "name": "package" },
{ "id": "mingcute:box-2-fill", "name": "box" },
{ "id": "mingcute:box-3-fill", "name": "box" },
{ "id": "mingcute:question-fill", "name": "question" },
{ "id": "mingcute:information-fill", "name": "information" },
{ "id": "mingcute:bulb-fill", "name": "bulb" },
{ "id": "mingcute:brain-fill", "name": "brain" },
{ "id": "mingcute:thought-fill", "name": "thought" },
{ "id": "mingcute:mortarboard-fill", "name": "mortarboard" },
{ "id": "mingcute:school-fill", "name": "school" },
{ "id": "mingcute:telescope-fill", "name": "telescope" },
{ "id": "mingcute:microscope-fill", "name": "microscope" },
{ "id": "mingcute:flask-fill", "name": "flask" },
{ "id": "mingcute:test-tube-fill", "name": "test tube" },
{ "id": "mingcute:science-fill", "name": "science" },
{ "id": "mingcute:formula-fill", "name": "formula" },
{ "id": "ph:sigma-fill", "name": "sigma" },
{ "id": "ph:math-operations-fill", "name": "math operations" },
{ "id": "ri:calculator-fill", "name": "calculator" },
{ "id": "ri:atom-fill", "name": "atom" },
{ "id": "ri:dna-fill", "name": "dna" },
{ "id": "mingcute:omega-fill", "name": "omega" },
{ "id": "mingcute:asterisk-fill", "name": "asterisk" },
{ "id": "mingcute:code-fill", "name": "code" },
{ "id": "mingcute:braces-fill", "name": "braces" },
{ "id": "fluent:braces-variable-24-filled", "name": "variable" },
{ "id": "ph:function-fill", "name": "function" },
{ "id": "ph:binary-fill", "name": "binary" },
{ "id": "mingcute:brackets-fill", "name": "brackets" },
{ "id": "mingcute:brackets-angle-fill", "name": "brackets angle" },
{ "id": "mingcute:markup-fill", "name": "markup" },
{ "id": "mingcute:terminal-fill", "name": "terminal" },
{ "id": "mingcute:terminal-box-fill", "name": "terminal box" },
{ "id": "fluent:window-dev-tools-24-filled", "name": "dev tools" },
{ "id": "mingcute:command-fill", "name": "command" },
{ "id": "mingcute:hotkey-fill", "name": "hotkey" },
{ "id": "mingcute:keyboard-fill", "name": "keyboard" },
{ "id": "mingcute:bug-fill", "name": "bug" },
{ "id": "mingcute:git-branch-fill", "name": "git branch" },
{ "id": "mingcute:git-commit-fill", "name": "git commit" },
{ "id": "mingcute:git-compare-fill", "name": "git compare" },
{ "id": "mingcute:git-merge-fill", "name": "git merge" },
{ "id": "mingcute:git-pull-request-fill", "name": "git pull request" },
{ "id": "mingcute:git-pull-request-close-fill", "name": "git pull request close" },
{ "id": "ri:git-repository-fill", "name": "git repository" },
{ "id": "ri:git-repository-private-fill", "name": "git repository private" },
{ "id": "mingcute:plugin-fill", "name": "plugin" },
{ "id": "mingcute:plugin-2-fill", "name": "plugin" },
{ "id": "ph:plugs-connected-fill", "name": "plugs connected" },
{ "id": "mingcute:components-fill", "name": "components" },
{ "id": "mingcute:layers-fill", "name": "layers" },
{ "id": "ph:stack-fill", "name": "stack" },
{ "id": "mingcute:layer-fill", "name": "layer" },
{ "id": "mingcute:cube-fill", "name": "cube" },
{ "id": "mingcute:cube-3d-fill", "name": "cube 3d" },
{ "id": "mingcute:hexagon-fill", "name": "hexagon" },
{ "id": "mingcute:hexagons-fill", "name": "hexagons" },
{ "id": "mingcute:process-fill", "name": "process" },
{ "id": "fluent:pipeline-24-filled", "name": "pipeline" },
{ "id": "fluent:flowchart-24-filled", "name": "flowchart" },
{ "id": "ph:queue-fill", "name": "queue" },
{ "id": "ri:infinity-fill", "name": "infinity" },
{ "id": "mingcute:transformation-fill", "name": "transformation" },
{ "id": "mingcute:random-fill", "name": "random" },
{ "id": "mingcute:repeat-fill", "name": "repeat" },
{ "id": "mingcute:shuffle-fill", "name": "shuffle" },
{ "id": "mingcute:switch-fill", "name": "switch" },
{ "id": "mingcute:toggle-right-fill", "name": "toggle right" },
{ "id": "mingcute:cursor-fill", "name": "cursor" },
{ "id": "mingcute:mouse-fill", "name": "mouse" },
{ "id": "mingcute:inspect-fill", "name": "inspect" },
{ "id": "mingcute:search-fill", "name": "search" },
{ "id": "mingcute:filter-fill", "name": "filter" },
{ "id": "mingcute:filter-2-fill", "name": "filter" },
{ "id": "mingcute:sort-ascending-fill", "name": "sort ascending" },
{ "id": "mingcute:sort-descending-fill", "name": "sort descending" },
{ "id": "mingcute:target-fill", "name": "target" },
{ "id": "mingcute:aiming-fill", "name": "aiming" },
{ "id": "mingcute:magic-1-fill", "name": "magic" },
{ "id": "mingcute:sparkles-fill", "name": "sparkles" },
{ "id": "mingcute:ai-fill", "name": "ai" },
{ "id": "mingcute:robot-fill", "name": "robot" },
{ "id": "mingcute:webhook-fill", "name": "webhook" },
{ "id": "mingcute:mqtt-fill", "name": "mqtt" },
{ "id": "mingcute:rss-fill", "name": "rss" },
{ "id": "mingcute:web-fill", "name": "web" },
{ "id": "mingcute:world-fill", "name": "world" },
{ "id": "mingcute:earth-fill", "name": "earth" },
{ "id": "mingcute:globe-fill", "name": "globe" },
{ "id": "mingcute:link-fill", "name": "link" },
{ "id": "mingcute:unlink-fill", "name": "unlink" },
{ "id": "mingcute:external-link-fill", "name": "external link" },
{ "id": "mingcute:qrcode-fill", "name": "qrcode" },
{ "id": "mingcute:barcode-fill", "name": "barcode" },
{ "id": "mingcute:scan-fill", "name": "scan" },
{ "id": "mingcute:server-fill", "name": "server" },
{ "id": "mingcute:server-2-fill", "name": "server" },
{ "id": "ri:instance-fill", "name": "instance" },
{ "id": "ph:shipping-container-fill", "name": "container" },
{ "id": "mingcute:storage-fill", "name": "storage" },
{ "id": "ri:database-2-fill", "name": "database" },
{ "id": "ri:hard-drive-2-fill", "name": "hard drive" },
{ "id": "mingcute:drive-fill", "name": "drive" },
{ "id": "mingcute:cylinder-fill", "name": "cylinder" },
{ "id": "mingcute:cylinder-2-fill", "name": "cylinder" },
{ "id": "mingcute:safe-box-fill", "name": "safe box" },
{ "id": "mingcute:usb-fill", "name": "usb" },
{ "id": "mingcute:usb-flash-disk-fill", "name": "usb flash disk" },
{ "id": "mingcute:router-modem-fill", "name": "router modem" },
{ "id": "mingcute:base-station-fill", "name": "base station" },
{ "id": "ph:network-fill", "name": "network" },
{ "id": "ph:broadcast-fill", "name": "broadcast" },
{ "id": "mingcute:wifi-fill", "name": "wifi" },
{ "id": "mingcute:signal-fill", "name": "signal" },
{ "id": "mingcute:bluetooth-fill", "name": "bluetooth" },
{ "id": "mingcute:nfc-fill", "name": "nfc" },
{ "id": "mingcute:cloud-fill", "name": "cloud" },
{ "id": "mingcute:cloud-2-fill", "name": "cloud" },
{ "id": "mingcute:clouds-fill", "name": "clouds" },
{ "id": "mingcute:cloud-lightning-fill", "name": "cloud lightning" },
{ "id": "fluent:cloud-sync-24-filled", "name": "cloud sync" },
{ "id": "mingcute:monitor-fill", "name": "monitor" },
{ "id": "mingcute:display-fill", "name": "display" },
{ "id": "mingcute:computer-fill", "name": "computer" },
{ "id": "mingcute:laptop-fill", "name": "laptop" },
{ "id": "mingcute:imac-fill", "name": "imac" },
{ "id": "mingcute:device-fill", "name": "device" },
{ "id": "mingcute:chip-fill", "name": "chip" },
{ "id": "ph:cpu-fill", "name": "cpu" },
{ "id": "ph:memory-fill", "name": "memory" },
{ "id": "ri:ram-fill", "name": "ram" },
{ "id": "ph:circuitry-fill", "name": "circuitry" },
{ "id": "mingcute:cellphone-fill", "name": "cellphone" },
{ "id": "mingcute:pad-fill", "name": "pad" },
{ "id": "mingcute:watch-fill", "name": "watch" },
{ "id": "mingcute:tv-1-fill", "name": "tv" },
{ "id": "mingcute:projector-fill", "name": "projector" },
{ "id": "mingcute:print-fill", "name": "print" },
{ "id": "mingcute:remote-fill", "name": "remote" },
{ "id": "mingcute:power-fill", "name": "power" },
{ "id": "mingcute:flash-fill", "name": "flash" },
{ "id": "mingcute:lightning-fill", "name": "lightning" },
{ "id": "mingcute:battery-fill", "name": "battery" },
{ "id": "mingcute:battery-charging-fill", "name": "battery charging" },
{ "id": "mingcute:charging-pile-fill", "name": "charging pile" },
{ "id": "mingcute:solar-panel-fill", "name": "solar panel" },
{ "id": "mingcute:high-voltage-power-fill", "name": "high voltage power" },
{ "id": "mingcute:engine-fill", "name": "engine" },
{ "id": "mingcute:fan-fill", "name": "fan" },
{ "id": "mingcute:thermometer-fill", "name": "thermometer" },
{ "id": "mingcute:fire-fill", "name": "fire" },
{ "id": "mingcute:flame-fill", "name": "flame" },
{ "id": "mingcute:drop-fill", "name": "drop" },
{ "id": "mingcute:wind-fill", "name": "wind" },
{ "id": "mingcute:tornado-fill", "name": "tornado" },
{ "id": "mingcute:thunderstorm-fill", "name": "thunderstorm" },
{ "id": "mingcute:sun-fill", "name": "sun" },
{ "id": "mingcute:moon-fill", "name": "moon" },
{ "id": "mingcute:moon-stars-fill", "name": "moon stars" },
{ "id": "mingcute:planet-fill", "name": "planet" },
{ "id": "mingcute:rocket-fill", "name": "rocket" },
{ "id": "mingcute:rocket-2-fill", "name": "rocket" },
{ "id": "mingcute:ufo-fill", "name": "ufo" },
{ "id": "mingcute:drone-fill", "name": "drone" },
{ "id": "mingcute:radar-fill", "name": "radar" },
{ "id": "mingcute:radar-2-fill", "name": "radar" },
{ "id": "mingcute:compass-fill", "name": "compass" },
{ "id": "mingcute:navigation-fill", "name": "navigation" },
{ "id": "mingcute:map-fill", "name": "map" },
{ "id": "mingcute:map-pin-fill", "name": "map pin" },
{ "id": "mingcute:location-fill", "name": "location" },
{ "id": "mingcute:route-fill", "name": "route" },
{ "id": "mingcute:directions-fill", "name": "directions" },
{ "id": "mingcute:road-fill", "name": "road" },
{ "id": "mingcute:bridge-fill", "name": "bridge" },
{ "id": "mingcute:tunnel-fill", "name": "tunnel" },
{ "id": "mingcute:tower-fill", "name": "tower" },
{ "id": "mingcute:tv-tower-fill", "name": "tv tower" },
{ "id": "mingcute:lighthouse-fill", "name": "lighthouse" },
{ "id": "mingcute:anchor-fill", "name": "anchor" },
{ "id": "mingcute:ship-fill", "name": "ship" },
{ "id": "mingcute:sailboat-fill", "name": "sailboat" },
{ "id": "mingcute:truck-fill", "name": "truck" },
{ "id": "mingcute:train-fill", "name": "train" },
{ "id": "mingcute:bus-fill", "name": "bus" },
{ "id": "mingcute:car-fill", "name": "car" },
{ "id": "mingcute:airplane-fill", "name": "airplane" },
{ "id": "mingcute:flight-takeoff-fill", "name": "flight takeoff" },
{ "id": "mingcute:flight-land-fill", "name": "flight land" },
{ "id": "mingcute:parachute-fill", "name": "parachute" },
{ "id": "mingcute:lifebuoy-fill", "name": "lifebuoy" },
{ "id": "mingcute:traffic-cone-fill", "name": "traffic cone" },
{ "id": "mingcute:traffic-lights-fill", "name": "traffic lights" },
{ "id": "mingcute:home-1-fill", "name": "home" },
{ "id": "mingcute:house-fill", "name": "house" },
{ "id": "mingcute:door-fill", "name": "door" },
{ "id": "mingcute:enter-door-fill", "name": "enter door" },
{ "id": "mingcute:exit-door-fill", "name": "exit door" },
{ "id": "mingcute:building-1-fill", "name": "building" },
{ "id": "mingcute:building-2-fill", "name": "building" },
{ "id": "mingcute:factory-fill", "name": "factory" },
{ "id": "mingcute:bank-fill", "name": "bank" },
{ "id": "mingcute:government-fill", "name": "government" },
{ "id": "mingcute:store-fill", "name": "store" },
{ "id": "mingcute:inventory-fill", "name": "inventory" },
{ "id": "mingcute:trunk-fill", "name": "trunk" },
{ "id": "mingcute:tower-crane-fill", "name": "tower crane" },
{ "id": "mingcute:ladder-fill", "name": "ladder" },
{ "id": "mingcute:hammer-fill", "name": "hammer" },
{ "id": "mingcute:screwdriver-fill", "name": "screwdriver" },
{ "id": "mingcute:tool-fill", "name": "tool" },
{ "id": "ph:toolbox-fill", "name": "toolbox" },
{ "id": "mingcute:settings-1-fill", "name": "settings" },
{ "id": "mingcute:settings-3-fill", "name": "settings" },
{ "id": "mingcute:settings-6-fill", "name": "settings" },
{ "id": "mingcute:pickax-fill", "name": "pickax" },
{ "id": "mingcute:axe-fill", "name": "axe" },
{ "id": "mingcute:shovel-fill", "name": "shovel" },
{ "id": "mingcute:magnet-fill", "name": "magnet" },
{ "id": "mingcute:broom-fill", "name": "broom" },
{ "id": "mingcute:recycle-fill", "name": "recycle" },
{ "id": "mingcute:wastebasket-fill", "name": "wastebasket" },
{ "id": "mingcute:delete-2-fill", "name": "delete" },
{ "id": "mingcute:chart-bar-fill", "name": "chart bar" },
{ "id": "mingcute:chart-bar-2-fill", "name": "chart bar" },
{ "id": "mingcute:chart-line-fill", "name": "chart line" },
{ "id": "mingcute:chart-pie-fill", "name": "chart pie" },
{ "id": "mingcute:chart-pie-2-fill", "name": "chart pie" },
{ "id": "mingcute:chart-vertical-fill", "name": "chart vertical" },
{ "id": "mingcute:chart-horizontal-fill", "name": "chart horizontal" },
{ "id": "mingcute:chart-decrease-fill", "name": "chart decrease" },
{ "id": "fluent:data-scatter-24-filled", "name": "scatter chart" },
{ "id": "fluent:data-treemap-24-filled", "name": "treemap" },
{ "id": "ph:graph-fill", "name": "graph" },
{ "id": "mingcute:trending-up-fill", "name": "trending up" },
{ "id": "mingcute:trending-down-fill", "name": "trending down" },
{ "id": "mingcute:stock-fill", "name": "stock" },
{ "id": "mingcute:performance-fill", "name": "performance" },
{ "id": "mingcute:dashboard-fill", "name": "dashboard" },
{ "id": "mingcute:dashboard-2-fill", "name": "dashboard" },
{ "id": "mingcute:dashboard-3-fill", "name": "dashboard" },
{ "id": "mingcute:dashboard-4-fill", "name": "dashboard" },
{ "id": "ph:gauge-fill", "name": "gauge" },
{ "id": "mingcute:counter-fill", "name": "counter" },
{ "id": "mingcute:percentage-fill", "name": "percentage" },
{ "id": "mingcute:wave-fill", "name": "wave" },
{ "id": "mingcute:sound-line-fill", "name": "sound line" },
{ "id": "mingcute:heartbeat-fill", "name": "heartbeat" },
{ "id": "mingcute:heartbeat-2-fill", "name": "heartbeat" },
{ "id": "mingcute:table-fill", "name": "table" },
{ "id": "mingcute:table-2-fill", "name": "table" },
{ "id": "mingcute:grid-fill", "name": "grid" },
{ "id": "mingcute:layout-fill", "name": "layout" },
{ "id": "mingcute:balance-fill", "name": "balance" },
{ "id": "mingcute:scale-fill", "name": "scale" },
{ "id": "mingcute:ruler-fill", "name": "ruler" },
{ "id": "mingcute:pencil-ruler-fill", "name": "pencil ruler" },
{ "id": "mingcute:gradienter-fill", "name": "gradienter" },
{ "id": "mingcute:time-fill", "name": "time" },
{ "id": "mingcute:clock-fill", "name": "clock" },
{ "id": "mingcute:stopwatch-fill", "name": "stopwatch" },
{ "id": "mingcute:sandglass-fill", "name": "sandglass" },
{ "id": "mingcute:hours-fill", "name": "hours" },
{ "id": "mingcute:history-fill", "name": "history" },
{ "id": "mingcute:schedule-fill", "name": "schedule" },
{ "id": "mingcute:calendar-fill", "name": "calendar" },
{ "id": "mingcute:calendar-month-fill", "name": "calendar month" },
{ "id": "mingcute:calendar-week-fill", "name": "calendar week" },
{ "id": "mingcute:alarm-1-fill", "name": "alarm" },
{ "id": "mingcute:key-1-fill", "name": "key" },
{ "id": "mingcute:key-2-fill", "name": "key" },
{ "id": "mingcute:keyhole-fill", "name": "keyhole" },
{ "id": "fluent:password-24-filled", "name": "password" },
{ "id": "mingcute:lock-fill", "name": "lock" },
{ "id": "mingcute:unlock-fill", "name": "unlock" },
{ "id": "mingcute:shield-fill", "name": "shield" },
{ "id": "mingcute:shield-shape-fill", "name": "shield shape" },
{ "id": "mingcute:safe-shield-fill", "name": "safe shield" },
{ "id": "mingcute:safety-certificate-fill", "name": "safety certificate" },
{ "id": "mingcute:safe-lock-fill", "name": "safe lock" },
{ "id": "mingcute:safe-alert-fill", "name": "safe alert" },
{ "id": "mingcute:fingerprint-fill", "name": "fingerprint" },
{ "id": "mingcute:faceid-fill", "name": "faceid" },
{ "id": "mingcute:incognito-mode-fill", "name": "incognito mode" },
{ "id": "mingcute:eye-fill", "name": "eye" },
{ "id": "mingcute:eye-close-fill", "name": "eye close" },
{ "id": "mingcute:virus-fill", "name": "virus" },
{ "id": "mingcute:bomb-fill", "name": "bomb" },
{ "id": "mingcute:skull-fill", "name": "skull" },
{ "id": "mingcute:ghost-fill", "name": "ghost" },
{ "id": "mingcute:sword-fill", "name": "sword" },
{ "id": "mingcute:notification-fill", "name": "notification" },
{ "id": "mingcute:bell-ringing-fill", "name": "bell ringing" },
{ "id": "mingcute:announcement-fill", "name": "announcement" },
{ "id": "mingcute:horn-fill", "name": "horn" },
{ "id": "mingcute:flag-1-fill", "name": "flag" },
{ "id": "mingcute:flag-2-fill", "name": "flag" },
{ "id": "mingcute:flag-3-fill", "name": "flag" },
{ "id": "mingcute:flag-4-fill", "name": "flag" },
{ "id": "mingcute:check-circle-fill", "name": "check circle" },
{ "id": "mingcute:close-circle-fill", "name": "close circle" },
{ "id": "mingcute:forbid-circle-fill", "name": "forbid circle" },
{ "id": "mingcute:minus-circle-fill", "name": "minus circle" },
{ "id": "mingcute:add-circle-fill", "name": "add circle" },
{ "id": "mingcute:alert-fill", "name": "alert" },
{ "id": "mingcute:alert-octagon-fill", "name": "alert octagon" },
{ "id": "mingcute:alert-diamond-fill", "name": "alert diamond" },
{ "id": "mingcute:warning-fill", "name": "warning" },
{ "id": "mingcute:fault-fill", "name": "fault" },
{ "id": "mingcute:stethoscope-fill", "name": "stethoscope" },
{ "id": "mingcute:first-aid-kit-fill", "name": "first aid kit" },
{ "id": "mingcute:capsule-fill", "name": "capsule" },
{ "id": "mingcute:hospital-fill", "name": "hospital" },
{ "id": "mingcute:medal-fill", "name": "medal" },
{ "id": "mingcute:trophy-fill", "name": "trophy" },
{ "id": "mingcute:award-fill", "name": "award" },
{ "id": "mingcute:star-fill", "name": "star" },
{ "id": "mingcute:heart-fill", "name": "heart" },
{ "id": "mingcute:thumb-up-fill", "name": "thumb up" },
{ "id": "mingcute:thumb-down-fill", "name": "thumb down" },
{ "id": "mingcute:user-1-fill", "name": "user" },
{ "id": "mingcute:user-3-fill", "name": "user" },
{ "id": "mingcute:user-add-fill", "name": "user add" },
{ "id": "mingcute:user-setting-fill", "name": "user setting" },
{ "id": "mingcute:user-security-fill", "name": "user security" },
{ "id": "mingcute:user-lock-fill", "name": "user lock" },
{ "id": "mingcute:user-star-fill", "name": "user star" },
{ "id": "mingcute:user-search-fill", "name": "user search" },
{ "id": "mingcute:group-fill", "name": "group" },
{ "id": "mingcute:group-2-fill", "name": "group" },
{ "id": "mingcute:contacts-fill", "name": "contacts" },
{ "id": "mingcute:department-fill", "name": "department" },
{ "id": "mingcute:idcard-fill", "name": "idcard" },
{ "id": "mingcute:profile-fill", "name": "profile" },
{ "id": "mingcute:invite-fill", "name": "invite" },
{ "id": "mingcute:briefcase-fill", "name": "briefcase" },
{ "id": "mingcute:suitcase-fill", "name": "suitcase" },
{ "id": "mingcute:mail-fill", "name": "mail" },
{ "id": "mingcute:mail-send-fill", "name": "mail send" },
{ "id": "mingcute:mail-open-fill", "name": "mail open" },
{ "id": "mingcute:chat-1-fill", "name": "chat" },
{ "id": "mingcute:chat-2-fill", "name": "chat" },
{ "id": "mingcute:chat-4-fill", "name": "chat" },
{ "id": "mingcute:message-1-fill", "name": "message" },
{ "id": "mingcute:message-3-fill", "name": "message" },
{ "id": "mingcute:comment-fill", "name": "comment" },
{ "id": "mingcute:phone-fill", "name": "phone" },
{ "id": "mingcute:phone-call-fill", "name": "phone call" },
{ "id": "mingcute:video-fill", "name": "video" },
{ "id": "mingcute:video-camera-fill", "name": "video camera" },
{ "id": "mingcute:mic-fill", "name": "mic" },
{ "id": "mingcute:headphone-fill", "name": "headphone" },
{ "id": "mingcute:speaker-fill", "name": "speaker" },
{ "id": "mingcute:volume-fill", "name": "volume" },
{ "id": "mingcute:live-fill", "name": "live" },
{ "id": "mingcute:hands-clapping-fill", "name": "hands clapping" },
{ "id": "mingcute:wave-hand-fill", "name": "wave hand" },
{ "id": "mingcute:hand-heart-fill", "name": "hand heart" },
{ "id": "ph:handshake-fill", "name": "handshake" },
{ "id": "mingcute:celebrate-fill", "name": "celebrate" },
{ "id": "mingcute:firework-fill", "name": "firework" },
{ "id": "mingcute:gift-fill", "name": "gift" },
{ "id": "mingcute:cake-fill", "name": "cake" },
{ "id": "mingcute:teacup-fill", "name": "teacup" },
{ "id": "mingcute:game-1-fill", "name": "game" },
{ "id": "mingcute:chess-fill", "name": "chess" },
{ "id": "mingcute:leaf-fill", "name": "leaf" },
{ "id": "mingcute:tree-2-fill", "name": "tree" },
{ "id": "mingcute:wallet-fill", "name": "wallet" },
{ "id": "mingcute:coin-fill", "name": "coin" },
{ "id": "mingcute:cash-fill", "name": "cash" },
{ "id": "mingcute:bank-card-fill", "name": "bank card" },
{ "id": "mingcute:bill-fill", "name": "bill" },
{ "id": "mingcute:pig-money-fill", "name": "pig money" },
{ "id": "mingcute:currency-dollar-fill", "name": "currency dollar" },
{ "id": "mingcute:currency-euro-fill", "name": "currency euro" },
{ "id": "mingcute:receive-money-fill", "name": "receive money" },
{ "id": "mingcute:exchange-dollar-fill", "name": "exchange dollar" },
{ "id": "mingcute:sale-fill", "name": "sale" },
{ "id": "mingcute:coupon-fill", "name": "coupon" },
{ "id": "mingcute:ticket-fill", "name": "ticket" },
{ "id": "mingcute:auction-fill", "name": "auction" },
{ "id": "mingcute:github-fill", "name": "github" },
{ "id": "mingcute:git-lab-fill", "name": "git lab" },
{ "id": "mingcute:linux-fill", "name": "linux" },
{ "id": "mingcute:ubuntu-fill", "name": "ubuntu" },
{ "id": "mingcute:windows-fill", "name": "windows" },
{ "id": "mingcute:apple-fill", "name": "apple" },
{ "id": "mingcute:android-fill", "name": "android" },
{ "id": "mingcute:chrome-fill", "name": "chrome" },
{ "id": "mingcute:firefox-fill", "name": "firefox" },
{ "id": "mingcute:safari-fill", "name": "safari" },
{ "id": "mingcute:edge-fill", "name": "edge" },
{ "id": "mingcute:vscode-fill", "name": "vscode" },
{ "id": "mingcute:react-fill", "name": "react" },
{ "id": "mingcute:vue-fill", "name": "vue" },
{ "id": "mingcute:figma-fill", "name": "figma" },
{ "id": "mingcute:notion-fill", "name": "notion" },
{ "id": "mingcute:discord-fill", "name": "discord" },
{ "id": "mingcute:telegram-fill", "name": "telegram" },
{ "id": "mingcute:dropbox-fill", "name": "dropbox" },
{ "id": "mingcute:stripe-fill", "name": "stripe" },
{ "id": "mingcute:paypal-fill", "name": "paypal" },
{ "id": "mingcute:youtube-fill", "name": "youtube" },
{ "id": "mingcute:reddit-fill", "name": "reddit" },
{ "id": "mingcute:mastodon-fill", "name": "mastodon" },
{ "id": "mingcute:openai-fill", "name": "openai" },
{ "id": "mingcute:claude-fill", "name": "claude" },
{ "id": "mingcute:copilot-fill", "name": "copilot" },
{ "id": "mingcute:mcp-fill", "name": "mcp" },
{ "id": "mingcute:deepseek-fill", "name": "deepseek" },
{ "id": "mingcute:gemini-fill", "name": "gemini" },
{ "id": "mingcute:grok-fill", "name": "grok" },
{ "id": "mingcute:perplexity-fill", "name": "perplexity" },
{ "id": "mingcute:vibe-coding-fill", "name": "vibe coding" },
{ "id": "simple-icons:kubernetes", "name": "kubernetes" },
{ "id": "simple-icons:docker", "name": "docker" },
{ "id": "simple-icons:containerd", "name": "containerd" },
{ "id": "simple-icons:helm", "name": "helm" },
{ "id": "simple-icons:rancher", "name": "rancher" },
{ "id": "simple-icons:k3s", "name": "k3s" },
{ "id": "simple-icons:talos", "name": "talos" },
{ "id": "simple-icons:redhatopenshift", "name": "openshift" },
{ "id": "simple-icons:istio", "name": "istio" },
{ "id": "simple-icons:linkerd", "name": "linkerd" },
{ "id": "simple-icons:envoyproxy", "name": "envoy" },
{ "id": "simple-icons:argo", "name": "argo" },
{ "id": "simple-icons:openstack", "name": "openstack" },
{ "id": "simple-icons:proxmox", "name": "proxmox" },
{ "id": "file-icons:vmware", "name": "vmware" },
{ "id": "simple-icons:virtualbox", "name": "virtualbox" },
{ "id": "simple-icons:qemu", "name": "qemu" },
{ "id": "simple-icons:portainer", "name": "portainer" },
{ "id": "simple-icons:terraform", "name": "terraform" },
{ "id": "simple-icons:ansible", "name": "ansible" },
{ "id": "simple-icons:puppet", "name": "puppet" },
{ "id": "simple-icons:chef", "name": "chef" },
{ "id": "simple-icons:pulumi", "name": "pulumi" },
{ "id": "simple-icons:hashicorp", "name": "hashicorp" },
{ "id": "simple-icons:vault", "name": "vault" },
{ "id": "simple-icons:consul", "name": "consul" },
{ "id": "simple-icons:nomad", "name": "nomad" },
{ "id": "simple-icons:backstage", "name": "backstage" },
{ "id": "simple-icons:prometheus", "name": "prometheus" },
{ "id": "simple-icons:grafana", "name": "grafana" },
{ "id": "simple-icons:opentelemetry", "name": "opentelemetry" },
{ "id": "simple-icons:jaeger", "name": "jaeger" },
{ "id": "simple-icons:thanos", "name": "thanos" },
{ "id": "simple-icons:victoriametrics", "name": "victoriametrics" },
{ "id": "simple-icons:influxdb", "name": "influxdb" },
{ "id": "simple-icons:elasticsearch", "name": "elasticsearch" },
{ "id": "simple-icons:kibana", "name": "kibana" },
{ "id": "simple-icons:opensearch", "name": "opensearch" },
{ "id": "simple-icons:sentry", "name": "sentry" },
{ "id": "simple-icons:datadog", "name": "datadog" },
{ "id": "simple-icons:newrelic", "name": "new relic" },
{ "id": "simple-icons:pagerduty", "name": "pagerduty" },
{ "id": "simple-icons:uptimekuma", "name": "uptime kuma" },
{ "id": "devicon-plain:postgresql", "name": "postgresql" },
{ "id": "devicon-plain:mysql", "name": "mysql" },
{ "id": "simple-icons:mariadb", "name": "mariadb" },
{ "id": "simple-icons:sqlite", "name": "sqlite" },
{ "id": "simple-icons:mongodb", "name": "mongodb" },
{ "id": "simple-icons:redis", "name": "redis" },
{ "id": "simple-icons:clickhouse", "name": "clickhouse" },
{ "id": "simple-icons:cockroachlabs", "name": "cockroachdb" },
{ "id": "simple-icons:etcd", "name": "etcd" },
{ "id": "simple-icons:meilisearch", "name": "meilisearch" },
{ "id": "simple-icons:minio", "name": "minio" },
{ "id": "simple-icons:rabbitmq", "name": "rabbitmq" },
{ "id": "simple-icons:apachekafka", "name": "kafka" },
{ "id": "cib:cassandra", "name": "cassandra" },
{ "id": "cib:apache-spark", "name": "spark" },
{ "id": "simple-icons:snowflake", "name": "snowflake (data cloud)" },
{ "id": "simple-icons:databricks", "name": "databricks" },
{ "id": "simple-icons:supabase", "name": "supabase" },
{ "id": "ri:firebase-fill", "name": "firebase" },
{ "id": "simple-icons:prisma", "name": "prisma" },
{ "id": "simple-icons:metabase", "name": "metabase" },
{ "id": "simple-icons:posthog", "name": "posthog" },
{ "id": "simple-icons:googleanalytics", "name": "google analytics" },
{ "id": "simple-icons:nginx", "name": "nginx" },
{ "id": "file-icons:caddy", "name": "caddy" },
{ "id": "simple-icons:kong", "name": "kong" },
{ "id": "simple-icons:cloudflare", "name": "cloudflare" },
{ "id": "simple-icons:letsencrypt", "name": "let's encrypt" },
{ "id": "simple-icons:wireguard", "name": "wireguard" },
{ "id": "simple-icons:openvpn", "name": "openvpn" },
{ "id": "simple-icons:tailscale", "name": "tailscale" },
{ "id": "simple-icons:git", "name": "git" },
{ "id": "simple-icons:gitea", "name": "gitea" },
{ "id": "simple-icons:bitbucket", "name": "bitbucket" },
{ "id": "devicon-plain:jenkins", "name": "jenkins" },
{ "id": "simple-icons:circleci", "name": "circleci" },
{ "id": "simple-icons:dependabot", "name": "dependabot" },
{ "id": "simple-icons:codecov", "name": "codecov" },
{ "id": "simple-icons:snyk", "name": "snyk" },
{ "id": "simple-icons:trivy", "name": "trivy" },
{ "id": "simple-icons:owasp", "name": "owasp" },
{ "id": "simple-icons:amazonwebservices", "name": "aws" },
{ "id": "simple-icons:googlecloud", "name": "google cloud" },
{ "id": "simple-icons:googlebigquery", "name": "bigquery" },
{ "id": "simple-icons:microsoftazure", "name": "azure" },
{ "id": "simple-icons:azuredevops", "name": "azure devops" },
{ "id": "simple-icons:digitalocean", "name": "digitalocean" },
{ "id": "simple-icons:hetzner", "name": "hetzner" },
{ "id": "simple-icons:vercel", "name": "vercel" },
{ "id": "cib:netlify", "name": "netlify" },
{ "id": "simple-icons:heroku", "name": "heroku" },
{ "id": "simple-icons:flydotio", "name": "fly.io" },
{ "id": "simple-icons:railway", "name": "railway" },
{ "id": "simple-icons:fedora", "name": "fedora" },
{ "id": "mdi:debian", "name": "debian" },
{ "id": "simple-icons:archlinux", "name": "arch linux" },
{ "id": "simple-icons:alpinelinux", "name": "alpine linux" },
{ "id": "simple-icons:nixos", "name": "nixos" },
{ "id": "simple-icons:rockylinux", "name": "rocky linux" },
{ "id": "ri:centos-fill", "name": "centos" },
{ "id": "simple-icons:redhat", "name": "red hat" },
{ "id": "simple-icons:freebsd", "name": "freebsd" },
{ "id": "simple-icons:raspberrypi", "name": "raspberry pi" },
{ "id": "simple-icons:homeassistant", "name": "home assistant" },
{ "id": "simple-icons:eclipsemosquitto", "name": "mosquitto" },
{ "id": "simple-icons:zapier", "name": "zapier" },
{ "id": "devicon-plain:go", "name": "go" },
{ "id": "simple-icons:rust", "name": "rust" },
{ "id": "simple-icons:python", "name": "python" },
{ "id": "simple-icons:typescript", "name": "typescript" },
{ "id": "simple-icons:javascript", "name": "javascript" },
{ "id": "devicon-plain:nodejs", "name": "node.js" },
{ "id": "simple-icons:bun", "name": "bun" },
{ "id": "simple-icons:deno", "name": "deno" },
{ "id": "ri:php-fill", "name": "php" },
{ "id": "simple-icons:ruby", "name": "ruby" },
{ "id": "simple-icons:kotlin", "name": "kotlin" },
{ "id": "simple-icons:swift", "name": "swift" },
{ "id": "devicon-plain:dot-net", "name": ".net" },
{ "id": "simple-icons:csharp", "name": "c#" },
{ "id": "simple-icons:cplusplus", "name": "c++" },
{ "id": "simple-icons:c", "name": "c" },
{ "id": "simple-icons:zig", "name": "zig" },
{ "id": "simple-icons:elixir", "name": "elixir" },
{ "id": "simple-icons:erlang", "name": "erlang" },
{ "id": "simple-icons:haskell", "name": "haskell" },
{ "id": "simple-icons:scala", "name": "scala" },
{ "id": "simple-icons:lua", "name": "lua" },
{ "id": "devicon-plain:perl", "name": "perl" },
{ "id": "simple-icons:r", "name": "r" },
{ "id": "simple-icons:julia", "name": "julia" },
{ "id": "simple-icons:webassembly", "name": "webassembly" },
{ "id": "simple-icons:powershell", "name": "powershell" },
{ "id": "simple-icons:gnubash", "name": "bash" },
{ "id": "simple-icons:vuedotjs", "name": "vue.js" },
{ "id": "simple-icons:nuxt", "name": "nuxt" },
{ "id": "simple-icons:nextdotjs", "name": "next.js" },
{ "id": "simple-icons:svelte", "name": "svelte" },
{ "id": "simple-icons:angular", "name": "angular" },
{ "id": "simple-icons:astro", "name": "astro" },
{ "id": "simple-icons:hono", "name": "hono" },
{ "id": "simple-icons:nestjs", "name": "nestjs" },
{ "id": "simple-icons:fastapi", "name": "fastapi" },
{ "id": "simple-icons:django", "name": "django" },
{ "id": "simple-icons:flask", "name": "flask (python)" },
{ "id": "simple-icons:spring", "name": "spring" },
{ "id": "devicon-plain:laravel", "name": "laravel" },
{ "id": "devicon-plain:rails", "name": "ruby on rails" },
{ "id": "simple-icons:tailwindcss", "name": "tailwind css" },
{ "id": "simple-icons:vite", "name": "vite" },
{ "id": "simple-icons:webpack", "name": "webpack" },
{ "id": "simple-icons:eslint", "name": "eslint" },
{ "id": "simple-icons:prettier", "name": "prettier" },
{ "id": "simple-icons:vitest", "name": "vitest" },
{ "id": "simple-icons:jest", "name": "jest" },
{ "id": "simple-icons:playwright", "name": "playwright" },
{ "id": "simple-icons:cypress", "name": "cypress" },
{ "id": "simple-icons:storybook", "name": "storybook" },
{ "id": "simple-icons:graphql", "name": "graphql" },
{ "id": "simple-icons:openapiinitiative", "name": "openapi" },
{ "id": "simple-icons:swagger", "name": "swagger" },
{ "id": "simple-icons:postman", "name": "postman" },
{ "id": "simple-icons:npm", "name": "npm" },
{ "id": "simple-icons:pnpm", "name": "pnpm" },
{ "id": "simple-icons:yarn", "name": "yarn" },
{ "id": "simple-icons:d3dotjs", "name": "d3.js" },
{ "id": "simple-icons:mermaid", "name": "mermaid" },
{ "id": "simple-icons:excalidraw", "name": "excalidraw" },
{ "id": "simple-icons:diagramsdotnet", "name": "diagrams.net" },
{ "id": "simple-icons:json", "name": "json" },
{ "id": "simple-icons:yaml", "name": "yaml" },
{ "id": "simple-icons:anthropic", "name": "anthropic" },
{ "id": "simple-icons:huggingface", "name": "hugging face" },
{ "id": "simple-icons:pytorch", "name": "pytorch" },
{ "id": "simple-icons:tensorflow", "name": "tensorflow" },
{ "id": "simple-icons:ollama", "name": "ollama" },
{ "id": "simple-icons:langchain", "name": "langchain" },
{ "id": "simple-icons:mistralai", "name": "mistral" },
{ "id": "simple-icons:cursor", "name": "cursor (editor)" },
{ "id": "simple-icons:slack", "name": "slack" },
{ "id": "simple-icons:jira", "name": "jira" },
{ "id": "simple-icons:confluence", "name": "confluence" },
{ "id": "simple-icons:trello", "name": "trello" },
{ "id": "simple-icons:linear", "name": "linear" },
{ "id": "simple-icons:clickup", "name": "clickup" },
{ "id": "simple-icons:asana", "name": "asana" },
{ "id": "simple-icons:miro", "name": "miro" },
{ "id": "simple-icons:mattermost", "name": "mattermost" },
{ "id": "simple-icons:googlemeet", "name": "google meet" },
{ "id": "simple-icons:microsoftteams", "name": "microsoft teams" },
{ "id": "ph:matrix-logo-fill", "name": "matrix" },
{ "id": "simple-icons:obsidian", "name": "obsidian" },
{ "id": "simple-icons:readthedocs", "name": "read the docs" },
{ "id": "simple-icons:docusaurus", "name": "docusaurus" },
{ "id": "simple-icons:gitbook", "name": "gitbook" },
{ "id": "simple-icons:stackoverflow", "name": "stack overflow" },
{ "id": "simple-icons:jetbrains", "name": "jetbrains" },
{ "id": "simple-icons:neovim", "name": "neovim" },
{ "id": "simple-icons:vim", "name": "vim" },
{ "id": "simple-icons:tmux", "name": "tmux" },
{ "id": "simple-icons:1password", "name": "1password" },
{ "id": "simple-icons:bitwarden", "name": "bitwarden" },
{ "id": "simple-icons:keycloak", "name": "keycloak" },
{ "id": "simple-icons:auth0", "name": "auth0" },
{ "id": "simple-icons:okta", "name": "okta" },
{ "id": "simple-icons:authelia", "name": "authelia" },
{ "id": "simple-icons:authentik", "name": "authentik" },
{ "id": "simple-icons:wireshark", "name": "wireshark" },
{ "id": "simple-icons:metasploit", "name": "metasploit" },
{ "id": "simple-icons:burpsuite", "name": "burp suite" },
{ "id": "simple-icons:rclone", "name": "rclone" },
{ "id": "simple-icons:truenas", "name": "truenas" },
{ "id": "simple-icons:twilio", "name": "twilio" },
{ "id": "simple-icons:sendgrid", "name": "sendgrid" }
]

View File

@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"github.com/oxynote/oxynote/server/core/internal/assistant/icon"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/pkg/strutil"
)
@ -239,7 +240,17 @@ func (b *builder) buildContainer(n node, typ document.BlockNodeType, child strin
switch typ {
case document.BlockNodeCalloutBlock:
blk.Attrs[document.AttrIcon] = cmp.Or(n.attrs[document.AttrIcon], _defaultCalloutIcon)
id := cmp.Or(n.attrs[document.AttrIcon], _defaultCalloutIcon)
// the default and a kept callout's own icon may be outside the
// picker's list. Both pass, since the caller did not pick them.
if id != _defaultCalloutIcon && id != stored.Attrs.Get(document.AttrIcon).String() {
if err := icon.Validate(id); err != nil {
return document.Block{}, fmt.Errorf("line %d: %w", n.line, err)
}
}
blk.Attrs[document.AttrIcon] = id
case document.BlockNodeOrderedList:
if start, err := strconv.Atoi(n.attrs[document.AttrStart]); err == nil && start > 1 {
blk.Attrs[document.AttrStart] = start

View File

@ -2,11 +2,13 @@ package markup
import (
"errors"
"fmt"
"maps"
"regexp"
"strings"
"testing"
"github.com/oxynote/oxynote/server/core/internal/assistant/icon"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/stretchr/testify/assert"
@ -191,9 +193,26 @@ func Test_Build(t *testing.T) {
"Id used twice": {Markup: `<p id="p2">x</p><p id="p2">y</p>`, Stored: root, Err: errors.New(`line 1: id "p2" is used twice; leave it out of the copy`)},
"Text outside an element": {Markup: "hello", Err: errors.New("line 1: text in the markup has to sit in an element such as <p>")},
"Text in a container": {Markup: "<callout>hi</callout>", Err: errors.New("line 1: text in <callout> has to sit in an element such as <p>")},
"Tag in a heading": {Markup: "<h2>a <b>b</b></h2>", Err: errors.New("line 1: a heading takes plain text, so <b> is not allowed in it")},
"Block inside text": {Markup: "<p>a <hr/></p>", Err: errors.New("line 1: <hr> is a block; it cannot sit inside text")},
"Link to a script": {Markup: `<p><a href=" Java Script:alert(1)">x</a></p>`, Err: errors.New("line 1: a link cannot use javascript:; link to a web address instead")},
"Callout icon the picker lacks": {
Markup: `<callout icon="lucide:rocket"><p>x</p></callout>`,
Err: fmt.Errorf("line 1: %w", icon.Validate("lucide:rocket")),
},
"Callout with the default icon": {
Markup: `<callout icon="lucide:text"><p>x</p></callout>`,
Result: "<callout icon=\"lucide:text\">\n <p>x</p>\n</callout>\n",
},
"Kept callout keeps an icon the picker lacks": {
Markup: `<callout id="c1" icon="lucide:info"><p>x</p></callout>`,
Stored: document.RootBlock{Content: []document.Block{{
Type: document.BlockNodeCalloutBlock,
Attrs: document.Attributes{document.AttrUID: "c1", document.AttrIcon: "lucide:info"},
Content: []document.Block{{Type: document.BlockNodeParagraph}},
}}},
Result: "<callout icon=\"lucide:info\">\n <p>x</p>\n</callout>\n",
},
"Tag in a heading": {Markup: "<h2>a <b>b</b></h2>", Err: errors.New("line 1: a heading takes plain text, so <b> is not allowed in it")},
"Block inside text": {Markup: "<p>a <hr/></p>", Err: errors.New("line 1: <hr> is a block; it cannot sit inside text")},
"Link to a script": {Markup: `<p><a href=" Java Script:alert(1)">x</a></p>`, Err: errors.New("line 1: a link cannot use javascript:; link to a web address instead")},
// links a person stored may use any other scheme, or not even
// parse, and a write that keeps them has to go through.
"Links of other schemes stay": {

View File

@ -309,7 +309,7 @@
"type": "calloutBlock",
"attrs": {
"uid": "c1",
"icon": "lucide:info"
"icon": "mingcute:information-fill"
},
"content": [
{

View File

@ -17,7 +17,7 @@
<task id="ti1" checked="true">Done</task>
<task id="ti2">Open</task>
</tasks>
<callout id="c1" icon="lucide:info">
<callout id="c1" icon="mingcute:information-fill">
<p id="c1p">Callout</p>
<ul id="cl">
<li id="cli">In callout</li>

View File

@ -18,6 +18,7 @@ var _contentDescription = "The blocks as XML, one element per block. " +
"<hr/>; <img src alt title width/>; <figma src width height/>; <metrics> of <metric>, which holds a JSON object of its attributes; " +
"<split_doc inversed> holding <left>, an <h1> then <p>, lists or <callout> then any <params header> of <param name type>description</param>, and <right>, a titled <pre>, <metric> or <mermaid>. " +
"Inline text takes <b>, <i>, <u>, <s>, <code> and <a href>; outside <pre>, <mermaid> and <metric>, whose content is raw, write & as &amp; and < as &lt;. " +
"A callout's icon takes the ids create_document's icon does. " +
"Mermaid ends a gantt task name or timeline period at its first colon, so write a colon inside one as #58;. " +
"A metric's attributes: " + markup.MetricReference() + "."

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/assistant/icon"
"github.com/oxynote/oxynote/server/core/internal/assistant/markup"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/tag"
@ -267,7 +268,7 @@ type createDocumentArgs struct {
// Name is the new document's display name. Required.
Name string `json:"name"`
// Icon is the lucide icon identifier. Empty falls back to the
// Icon is an icon id the picker offers. Empty falls back to the
// default icon.
Icon string `json:"icon"`
@ -282,6 +283,10 @@ func (a createDocumentArgs) Validate() error {
return errRequired("name")
}
if a.Icon != "" {
return icon.Validate(a.Icon)
}
return nil
}
@ -293,7 +298,7 @@ func (createDocument) Info() Info {
Description: "Create a document and return {document_id, branch_id}, the branch_id being its default branch. It starts with one empty paragraph; fill it with insert_blocks at position end on that branch.",
Properties: map[string]any{
"name": map[string]any{"type": "string", "description": "The display name."},
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:file-code-fill. Defaults to mingcute:document-2-fill."},
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id from the icon picker, mostly MingCute fills such as mingcute:file-code-fill; another id is refused, naming the closest ones. Defaults to mingcute:document-2-fill."},
"parent_id": map[string]any{"type": "string", "description": "Optional. The parent document id; omit it for the organisation root."},
},
Required: []string{"name"},
@ -324,14 +329,14 @@ func (createDocument) Execute(inp *input) (string, error) {
return "", err
}
icon := in.Icon
if icon == "" {
icon = "mingcute:document-2-fill"
iconID := in.Icon
if iconID == "" {
iconID = "mingcute:document-2-fill"
}
doc := document.NewDocument(document.CreateInput{
Name: in.Name,
Icon: icon,
Icon: iconID,
ParentID: in.ParentID,
}, inp.orgID, inp.userID)
@ -448,6 +453,12 @@ func (a updateDocumentArgs) Validate() error {
return errors.New("give at least one of name, icon or parent_id")
}
if a.Icon != "" {
if err := icon.Validate(a.Icon); err != nil {
return err
}
}
if a.ParentID != nil && *a.ParentID != "" {
if _, err := xid.FromString(*a.ParentID); err != nil {
return fmt.Errorf("parent_id: %w", err)
@ -511,7 +522,7 @@ func (updateDocument) Info() Info {
Properties: map[string]any{
"document_id": map[string]any{"type": "string", "description": _documentIDDescription},
"name": map[string]any{"type": "string", "description": "Optional. The new display name."},
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id; the sidebar uses MingCute fills, such as mingcute:rocket-fill."},
document.AttrIcon: map[string]any{"type": "string", "description": "Optional. An Iconify id from the icon picker, mostly MingCute fills such as mingcute:rocket-fill; another id is refused, naming the closest ones."},
"parent_id": map[string]any{"type": "string", "description": "Optional. The new parent document id, or an empty string for the organisation root."},
},
Required: []string{"document_id"},

View File

@ -323,6 +323,13 @@ func Test_createDocumentArgs_Validate(t *testing.T) {
assertValidate(t, createDocumentArgs{Name: "n"}, map[string]Args{
"name": createDocumentArgs{},
})
require.NoError(t, createDocumentArgs{Name: "n", Icon: "mingcute:rocket-fill"}.Validate())
// an icon the picker does not offer is refused, naming close ones.
err := createDocumentArgs{Name: "n", Icon: "lucide:rocket"}.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "mingcute:rocket-fill")
}
func Test_createDocument_Info(t *testing.T) {
@ -395,7 +402,7 @@ func Test_createDocument_Execute(t *testing.T) {
},
"Created under a parent": {
DB: stubCreateDB(nil),
Args: `{"name":"Runbook","icon":"lucide:rocket","parent_id":"` + parentID.String() + `"}`,
Args: `{"name":"Runbook","icon":"mingcute:rocket-fill","parent_id":"` + parentID.String() + `"}`,
Notify: 1,
},
}
@ -616,7 +623,7 @@ func Test_updateDocumentArgs_Validate(t *testing.T) {
})
// any one change is enough, and the root is a change.
require.NoError(t, updateDocumentArgs{DocumentID: _testDocID, Icon: "i"}.Validate())
require.NoError(t, updateDocumentArgs{DocumentID: _testDocID, Icon: "mingcute:rocket-fill"}.Validate())
require.NoError(t, updateDocumentArgs{DocumentID: _testDocID, ParentID: &root}.Validate())
// a call that changes nothing is refused, named by what it takes.
@ -628,6 +635,11 @@ func Test_updateDocumentArgs_Validate(t *testing.T) {
err = updateDocumentArgs{DocumentID: _testDocID, ParentID: &bad}.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "parent_id")
// an icon the picker does not offer is refused, naming close ones.
err = updateDocumentArgs{DocumentID: _testDocID, Icon: "lucide:rocket"}.Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "mingcute:rocket-fill")
}
func Test_updateDocumentArgs_parent(t *testing.T) {
@ -709,8 +721,8 @@ func Test_updateDocument_Summary(t *testing.T) {
"Icon": {Args: doc + `,"icon":"mingcute:rocket-fill"}`, Result: "Set the icon to mingcute:rocket-fill"},
"Root": {Args: doc + `,"parent_id":""}`, Result: "Move it to the org root"},
"Parent": {Args: doc + `,"parent_id":"` + xid.New().String() + `"}`, Result: "Move it under another document"},
"Two": {Args: doc + `,"name":"Playbook","icon":"i"}`, Result: `Rename Runbook to "Playbook" and set the icon to i`},
"All three": {Args: doc + `,"name":"Playbook","icon":"i","parent_id":""}`, Result: `Rename Runbook to "Playbook", set the icon to i and move it to the org root`},
"Two": {Args: doc + `,"name":"Playbook","icon":"mingcute:rocket-fill"}`, Result: `Rename Runbook to "Playbook" and set the icon to mingcute:rocket-fill`},
"All three": {Args: doc + `,"name":"Playbook","icon":"mingcute:rocket-fill","parent_id":""}`, Result: `Rename Runbook to "Playbook", set the icon to mingcute:rocket-fill and move it to the org root`},
}
for cn, c := range cc {
@ -799,20 +811,20 @@ func Test_updateDocument_Execute(t *testing.T) {
"Icon set": {
DB: stubDocumentDB(),
Applier: stubApplier(),
Args: doc + `,"icon":"i"}`,
Args: doc + `,"icon":"mingcute:rocket-fill"}`,
Ops: 1,
Notify: 1,
Result: `{"document_id":"` + _testDocID.String() + `","icon":"i"}`,
Result: `{"document_id":"` + _testDocID.String() + `","icon":"mingcute:rocket-fill"}`,
},
// name and icon travel in one batch, so the live document
// changes once.
"Renamed and icon set": {
DB: stubDocumentDB(),
Applier: stubApplier(),
Args: doc + `,"name":"Playbook","icon":"i"}`,
Args: doc + `,"name":"Playbook","icon":"mingcute:rocket-fill"}`,
Ops: 2,
Notify: 1,
Result: `{"document_id":"` + _testDocID.String() + `","name":"Playbook","icon":"i"}`,
Result: `{"document_id":"` + _testDocID.String() + `","name":"Playbook","icon":"mingcute:rocket-fill"}`,
},
"Error returned by db.CheckDocumentExists": {
DB: moveDB(assert.AnError, false, nil, nil),

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@ import { icons as ph } from "@iconify-json/ph"
import { icons as ri } from "@iconify-json/ri"
import { icons as simpleIcons } from "@iconify-json/simple-icons"
import { describe, it } from "vitest"
import coreIcons from "~/../../server/core/internal/assistant/icon/icons.json"
import { selectableIconList, selectableIcons } from "./icon"
const collections: Record<string, IconifyJSON | undefined> = {
@ -23,6 +24,15 @@ const collections: Record<string, IconifyJSON | undefined> = {
"simple-icons": simpleIcons,
}
describe("selectableIcons", () => {
// the assistant refuses icons outside its own copy of this list.
it("matches the list core validates assistant icons against", ({
expect,
}) => {
expect(selectableIcons).toEqual(coreIcons)
})
})
describe("selectableIconList", () => {
it("returns the id of every selectable icon in order", ({ expect }) => {
const list = selectableIconList()