Skip to content
Open
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
4 changes: 3 additions & 1 deletion shortcuts/doc/docs_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,10 @@ var DocsCreate = common.Shortcut{
}

func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
WarnCalloutType(md, runtime.IO().ErrOut)
args := map[string]interface{}{
"markdown": runtime.Str("markdown"),
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
Expand Down
2 changes: 2 additions & 0 deletions shortcuts/doc/docs_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"mode": runtime.Str("mode"),
}
if v := runtime.Str("markdown"); v != "" {
WarnCalloutType(v, runtime.IO().ErrOut)

Check warning on line 75 in shortcuts/doc/docs_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_update.go#L75

Added line #L75 was not covered by tests
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
Expand Down Expand Up @@ -105,6 +106,7 @@
"mode": mode,
}
if markdown != "" {
WarnCalloutType(markdown, runtime.IO().ErrOut)

Check warning on line 109 in shortcuts/doc/docs_update.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/docs_update.go#L109

Added line #L109 was not covered by tests
args["markdown"] = markdown
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
Expand Down
104 changes: 104 additions & 0 deletions shortcuts/doc/markdown_fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package doc

import (
"fmt"
"io"
"regexp"
"strings"
"unicode"
Expand Down Expand Up @@ -306,6 +308,108 @@
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}

// calloutTypeColors maps the semantic type= shorthand to a recommended
// [background-color, border-color] pair for Feishu callout blocks.
// Used only for hint messages β€” the Markdown itself is never rewritten.
var calloutTypeColors = map[string][2]string{
"warning": {"light-yellow", "yellow"},
"caution": {"light-orange", "orange"},
"note": {"light-blue", "blue"},
"info": {"light-blue", "blue"},
"tip": {"light-green", "green"},
"success": {"light-green", "green"},
"check": {"light-green", "green"},
"error": {"light-red", "red"},
"danger": {"light-red", "red"},
"important": {"light-purple", "purple"},
}

// calloutOpenTagRe matches a <callout …> opening tag.
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)

// calloutTypeAttrRe extracts the value of a type= attribute (single or double
// quoted) from a callout opening tag's attribute string.
//
// The boundary before `type=` is deliberately tighter than a plain `\b`.
// `\b` would sit between a `-` and a letter (both non-word vs word), which
// makes `data-type="warning"` match as if it were a bare `type="warning"`.
// Anchoring on start-of-string or whitespace prevents that false positive
// while still matching real attributes like `<callout type="warning" ...>`.
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)

// calloutBackgroundColorAttrRe matches a background-color= attribute name
// with optional whitespace around the equals sign, so forms like
// `background-color="..."` and `background-color = "..."` are both accepted.
var calloutBackgroundColorAttrRe = regexp.MustCompile(`\bbackground-color\s*=`)

// WarnCalloutType scans md for callout tags that carry a type= attribute but
// no background-color= attribute, then writes a hint line to w for each one
// suggesting the explicit Feishu color attributes to use instead.
//
// The Markdown is not modified β€” the caller is responsible for acting on the
// hints or ignoring them. This keeps the create/update path transparent: user
// input reaches create-doc exactly as written.
Comment thread
herbertliu marked this conversation as resolved.
//
// Callout tags inside fenced code blocks are skipped, since those are literal
// documentation samples (e.g. showing the user how to write a callout), not
// tags the user actually wants to create. Both backtick and tilde fences are
// recognized via the shared codeFenceOpenMarker/isCodeFenceClose helpers.
func WarnCalloutType(md string, w io.Writer) {
Comment thread
herbertliu marked this conversation as resolved.
lines := strings.Split(md, "\n")
inFence := false
var fenceMarker string
for _, line := range lines {
if inFence {
if isCodeFenceClose(line, fenceMarker) {
inFence = false
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
inFence = true
fenceMarker = marker
continue
}
scanCalloutTagsForWarning(line, w)
}
}

// scanCalloutTagsForWarning iterates every <callout …> opening tag in prose
// and writes a single hint line to w for each tag that has a known type=
// but no background-color=. FindAllStringIndex is used instead of
// ReplaceAllStringFunc so the intent (iterate-and-report) is clear and the
// built-but-discarded result string no longer confuses readers.
func scanCalloutTagsForWarning(prose string, w io.Writer) {
for _, loc := range calloutOpenTagRe.FindAllStringIndex(prose, -1) {
tag := prose[loc[0]:loc[1]]
attrs := ""
if m := calloutOpenTagRe.FindStringSubmatch(tag); len(m) == 2 {
attrs = m[1]
}
// Skip tags that already carry an explicit background-color.
if calloutBackgroundColorAttrRe.MatchString(attrs) {
continue
}
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
if len(parts) < 3 {
continue // no type= attribute

Check warning on line 396 in shortcuts/doc/markdown_fix.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/doc/markdown_fix.go#L396

Added line #L396 was not covered by tests
}
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
typeName := parts[1]
if typeName == "" {
typeName = parts[2]
}
colors, ok := calloutTypeColors[typeName]
if !ok {
continue // unknown type β€” no hint to give
}
fmt.Fprintf(w,
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
typeName, colors[0], colors[1])
}
}

// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{
Expand Down
108 changes: 108 additions & 0 deletions shortcuts/doc/markdown_fix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,114 @@ func TestFixExportedMarkdown(t *testing.T) {
}
}

func TestWarnCalloutType(t *testing.T) {
tests := []struct {
name string
input string
wantHint bool // whether a hint line is expected
hintContains string // substring the hint must contain
}{
{
name: "warning type without background-color emits hint",
input: `<callout type="warning" emoji="πŸ“">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "info type without background-color emits hint",
input: `<callout type="info" emoji="ℹ️">`,
wantHint: true,
hintContains: `background-color="light-blue"`,
},
{
name: "single-quoted type attribute emits hint",
input: `<callout type='warning' emoji="πŸ“">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "explicit background-color suppresses hint",
input: `<callout type="warning" emoji="πŸ“" background-color="light-red">`,
wantHint: false,
},
{
name: "whitespace around equals is tolerated in background-color",
input: `<callout type="warning" emoji="πŸ“" background-color = "light-red">`,
wantHint: false,
},
{
name: "unknown type emits no hint",
input: `<callout type="custom" emoji="πŸ”₯">`,
wantHint: false,
},
{
name: "no type attribute emits no hint",
input: `<callout emoji="πŸ’‘" background-color="light-green">`,
wantHint: false,
},
{
name: "non-callout tag emits no hint",
input: `<div type="warning">`,
wantHint: false,
},
{
name: "hint includes border-color suggestion",
input: `<callout type="error" emoji="❌">`,
wantHint: true,
hintContains: `border-color="red"`,
},
{
// Regression: the `\btype=` boundary used to match `data-type="..."`
// because `\b` sits between a `-` (non-word) and the `t` of `type`.
// A real `data-type` attribute must not produce a callout hint.
name: "data-type attribute does not trigger hint",
input: `<callout data-type="warning" emoji="πŸ“" background-color="light-yellow">`,
wantHint: false,
},
{
// Callout samples inside a fenced code block are documentation,
// not tags the user wants to create β€” the hint must not fire.
name: "callout tag inside fenced code block is ignored",
input: "see how to use it:\n```markdown\n<callout type=\"warning\" emoji=\"πŸ“\">text</callout>\n```",
wantHint: false,
},
{
// Callout samples inside tilde fence also skipped.
name: "callout tag inside tilde fenced code block is ignored",
input: "example:\n~~~\n<callout type=\"info\" emoji=\"πŸ’‘\">hi</callout>\n~~~",
wantHint: false,
},
{
// A real callout in prose next to a fenced sample still fires
// for the prose one only.
name: "real callout outside fence still flags when fence contains another",
input: "live: <callout type=\"warning\" emoji=\"πŸ“\">real</callout>\n```\n<callout type=\"info\">sample</callout>\n```",
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
WarnCalloutType(tt.input, &buf)
got := buf.String()
if tt.wantHint {
if got == "" {
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
return
}
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
}
} else {
if got != "" {
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
}
}
})
}
}

func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading