Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions api/admonition.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package api

import (
"fmt"
"strings"

"github.com/flanksource/clicky/api/icons"
)

// Severity classifies an Admonition callout. The zero value is SeverityNote.
type Severity int

const (
SeverityNote Severity = iota
SeverityInfo
SeverityTip
SeverityWarning
SeverityDanger
)

// String returns the canonical lower-case severity keyword, used both as the
// admonition header word and the HTML class suffix.
func (s Severity) String() string {
switch s {
case SeverityInfo:
return "info"
case SeverityTip:
return "tip"
case SeverityWarning:
return "warning"
case SeverityDanger:
return "danger"
default:
return "note"
}
}

// Icon returns the status icon conventionally paired with the severity.
func (s Severity) Icon() icons.Icon {
switch s {
case SeverityInfo:
return icons.Info
case SeverityTip:
return icons.Success
case SeverityWarning:
return icons.Warning
case SeverityDanger:
return icons.Error
default:
return icons.Info
}
}

// ParseSeverity maps an author-supplied severity word to a Severity. Unknown
// words fall through to SeverityNote so callers preserve the raw text via the
// admonition body rather than failing.
func ParseSeverity(s string) Severity {
switch strings.ToLower(strings.TrimSpace(s)) {
case "info", "information", "note-info":
return SeverityInfo
case "tip", "success", "hint":
return SeverityTip
case "warning", "warn", "caution":
return SeverityWarning
case "danger", "error", "critical", "failure":
return SeverityDanger
default:
return SeverityNote
}
}

// Admonition is a callout block (`!!! <severity> <title>`) with an indented
// body. Title and Body are each a single Textable; Title may be nil (header
// shows the severity word only) and Body may be nil (header only).
type Admonition struct {
Severity Severity
Title Textable
Body Textable
}

// header returns the plain `!!! <severity> <title-text>` line used by the
// text-oriented renderers.
func (a Admonition) header() string {
h := "!!! " + a.Severity.String()
if a.Title != nil {
if title := a.Title.String(); title != "" {
h += " " + title
}
}
return h
}

func indentLines(s, prefix string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}

func (a Admonition) String() string {
if a.Body == nil {
return a.header()
}
return a.header() + "\n" + indentLines(a.Body.String(), " ")
}

func (a Admonition) ANSI() string {
head := Text{Content: a.header(), Style: "font-bold"}.ANSI()
if a.Body == nil {
return head
}
return head + "\n" + indentLines(a.Body.ANSI(), " ")
}

func (a Admonition) HTML() string {
title := ""
if a.Title != nil {
title = a.Title.HTML()
}
body := ""
if a.Body != nil {
body = a.Body.HTML()
}
return fmt.Sprintf("<div class=%q><p>%s</p>%s</div>",
"admonition admonition-"+a.Severity.String(), title, body)
}

func (a Admonition) Markdown() string {
if a.Body == nil {
return a.header()
}
return a.header() + "\n" + indentLines(a.Body.Markdown(), " ")
}
61 changes: 61 additions & 0 deletions api/admonition_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package api

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Severity", func() {
It("round-trips known severity words through ParseSeverity/String", func() {
for word, sev := range map[string]Severity{
"note": SeverityNote,
"info": SeverityInfo,
"tip": SeverityTip,
"warning": SeverityWarning,
"danger": SeverityDanger,
"error": SeverityDanger,
"critical": SeverityDanger,
} {
Expect(ParseSeverity(word)).To(Equal(sev), "ParseSeverity(%q)", word)
}
Expect(SeverityWarning.String()).To(Equal("warning"))
})

It("falls back to note for unknown words", func() {
Expect(ParseSeverity("nonsense")).To(Equal(SeverityNote))
})
})

var _ = Describe("Admonition", func() {
build := func() Admonition {
return Admonition{
Severity: SeverityWarning,
Title: Text{Content: "Net income changed sign"},
Body: Text{Content: "review the statement"},
}
}

It("renders the !!! header with severity and title in text formats", func() {
a := build()
Expect(a.String()).To(HavePrefix("!!! warning Net income changed sign"))
Expect(a.Markdown()).To(HavePrefix("!!! warning Net income changed sign"))
Expect(a.ANSI()).To(ContainSubstring("Net income changed sign"))
})

It("indents the body four spaces in text and markdown", func() {
a := build()
Expect(a.String()).To(ContainSubstring(" review the statement"))
Expect(a.Markdown()).To(ContainSubstring(" review the statement"))
})

It("renders an admonition div keyed by severity in HTML", func() {
a := build()
Expect(a.HTML()).To(ContainSubstring(`class="admonition admonition-warning"`))
Expect(a.HTML()).To(ContainSubstring("Net income changed sign"))
})

It("renders header only when body is nil", func() {
a := Admonition{Severity: SeverityNote, Title: Text{Content: "heads up"}}
Expect(a.String()).To(Equal("!!! note heads up"))
})
})
2 changes: 2 additions & 0 deletions api/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ func convertToTextable(val any) Textable {
}

switch v := val.(type) {
case PrettyShort:
return v.PrettyShort()
case Textable:
return v
case Pretty:
Expand Down
66 changes: 66 additions & 0 deletions api/keyed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package api

import "encoding/json"

// Keyed wraps a Textable with a data key. Rendering passes through to the
// wrapped value unchanged in every format; only JSON serialization differs —
// the value marshals under Key as a single-field object. Use it when a block in
// a document needs a stable identifier for structured output (e.g. a table keyed
// by its heading) without altering how it renders.
type Keyed struct {
Key string
Value Textable
}

func (k Keyed) String() string { return textableString(k.Value) }
func (k Keyed) ANSI() string { return textableANSI(k.Value) }
func (k Keyed) HTML() string { return textableHTML(k.Value) }
func (k Keyed) Markdown() string { return textableMarkdown(k.Value) }

// MarshalJSON emits {Key: Value}. The wrapped value serializes via its own
// MarshalJSON when it implements json.Marshaler, otherwise via its rendered
// string.
func (k Keyed) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{k.Key: jsonValue(k.Value)})
}

func textableString(t Textable) string {
if t == nil {
return ""
}
return t.String()
}

func textableANSI(t Textable) string {
if t == nil {
return ""
}
return t.ANSI()
}

func textableHTML(t Textable) string {
if t == nil {
return ""
}
return t.HTML()
}

func textableMarkdown(t Textable) string {
if t == nil {
return ""
}
return t.Markdown()
}

// jsonValue returns the wrapped value as-is when it can marshal itself (so a
// TextTable serializes to its row array, an Amount to its formatted string),
// falling back to the rendered string otherwise.
func jsonValue(t Textable) any {
if t == nil {
return nil
}
if _, ok := t.(json.Marshaler); ok {
return t
}
return t.String()
}
39 changes: 39 additions & 0 deletions api/keyed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package api

import (
"encoding/json"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Keyed", func() {
It("renders identically to the wrapped value in every format", func() {
inner := Text{Content: "hello", Style: "font-bold"}
k := Keyed{Key: "greeting", Value: inner}
Expect(k.String()).To(Equal(inner.String()))
Expect(k.ANSI()).To(Equal(inner.ANSI()))
Expect(k.HTML()).To(Equal(inner.HTML()))
Expect(k.Markdown()).To(Equal(inner.Markdown()))
})

It("marshals JSON as a single-field object keyed by Key", func() {
k := Keyed{Key: "greeting", Value: Text{Content: "hi"}}
raw, err := json.Marshal(k)
Expect(err).ToNot(HaveOccurred())
Expect(string(raw)).To(Equal(`{"greeting":"hi"}`))
})

It("delegates JSON to a wrapped value that marshals itself", func() {
table := TextTable{
Headers: TextList{Text{Content: "A"}},
FieldNames: []string{"a"},
Rows: []TableRow{{"a": TypedValue{Textable: Text{Content: "1"}}}},
}
k := Keyed{Key: "rows", Value: table}
raw, err := json.Marshal(k)
Expect(err).ToNot(HaveOccurred())
Expect(string(raw)).To(ContainSubstring(`"rows":[`))
Expect(string(raw)).To(ContainSubstring(`"a":"1"`))
})
})
42 changes: 41 additions & 1 deletion api/link.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package api

import "fmt"
import (
"encoding/json"
"fmt"
)

type LinkTarget string

Expand All @@ -18,12 +21,25 @@ type Link struct {
Href string
Target LinkTarget
Content Text
// JSON is an optional structured payload carried alongside the link. Unlike
// Text, a Link serializes to a structured object (see MarshalJSON), so this
// payload survives JSON encoding and reaches structured-JSON consumers (e.g.
// a web UI that renders an <a> from the href and uses the payload for
// client-side navigation). It is ignored by String/ANSI/Markdown/HTML.
JSON any
}

func NewLink(href string) Link {
return Link{Href: href}
}

// WithJSON attaches a structured payload to the link. The payload is emitted by
// MarshalJSON under the "json" key; it does not affect text rendering.
func (l Link) WithJSON(v any) Link {
l.JSON = v
return l
}

func (l Link) Add(child Textable) Link {
l.Content = l.Content.Add(child)
return l
Expand Down Expand Up @@ -119,6 +135,30 @@ func (l Link) HTML() string {
return fmt.Sprintf(`<a href="%s"%s%s>%s</a>`, htmlEscapeString(l.Href), target, rel, content)
}

// MarshalJSON serializes a Link as a structured object so its href and payload
// survive JSON encoding. This is deliberately richer than Text.MarshalJSON
// (which flattens to a plain string): a structured-JSON consumer can render the
// link as an anchor and use the payload for navigation. Only non-empty fields
// are emitted.
func (l Link) MarshalJSON() ([]byte, error) {
out := map[string]any{"text": l.Content.String()}
if l.Href != "" {
out["href"] = l.Href
}
if l.Target != "" {
out["target"] = string(l.Target)
}
if l.Content.Tooltip != nil {
if tip := l.Content.Tooltip.String(); tip != "" {
out["tooltip"] = tip
}
}
if l.JSON != nil {
out["json"] = l.JSON
}
return json.Marshal(out)
}

type LinkCommand struct {
Command string
Args []string
Expand Down
Loading
Loading