diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 69ec15c8..b936371d 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -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 diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index 04ae4bc1..6c20bf61 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -75,6 +75,7 @@ var DocsUpdate = common.Shortcut{ "mode": runtime.Str("mode"), } if v := runtime.Str("markdown"); v != "" { + WarnCalloutType(v, runtime.IO().ErrOut) args["markdown"] = v } if v := runtime.Str("selection-with-ellipsis"); v != "" { @@ -108,6 +109,7 @@ var DocsUpdate = common.Shortcut{ "mode": mode, } if markdown != "" { + WarnCalloutType(markdown, runtime.IO().ErrOut) args["markdown"] = markdown } if v := runtime.Str("selection-with-ellipsis"); v != "" { diff --git a/shortcuts/doc/markdown_fix.go b/shortcuts/doc/markdown_fix.go index 1ead7a61..62c4727d 100644 --- a/shortcuts/doc/markdown_fix.go +++ b/shortcuts/doc/markdown_fix.go @@ -4,6 +4,8 @@ package doc import ( + "fmt" + "io" "regexp" "strings" "unicode" @@ -306,6 +308,71 @@ func fixSetextAmbiguity(md string) string { 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 opening tag. +var calloutOpenTagRe = regexp.MustCompile(`]*)?>`) + +// calloutTypeAttrRe extracts the value of a type= attribute (single or double +// quoted) from a callout opening tag's attribute string. +var calloutTypeAttrRe = regexp.MustCompile(`\btype=(?:"([^"]*)"|'([^']*)')`) + +// 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. +func WarnCalloutType(md string, w io.Writer) { + calloutOpenTagRe.ReplaceAllStringFunc(md, func(tag string) string { + 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) { + return tag + } + parts := calloutTypeAttrRe.FindStringSubmatch(attrs) + if len(parts) < 2 { + return tag // no type= attribute + } + // 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 { + return tag // 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]) + return tag + }) +} + // calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual // Unicode emoji characters that create-doc accepts. var calloutEmojiAliases = map[string]string{ diff --git a/shortcuts/doc/markdown_fix_test.go b/shortcuts/doc/markdown_fix_test.go index 81ac26a9..0198a539 100644 --- a/shortcuts/doc/markdown_fix_test.go +++ b/shortcuts/doc/markdown_fix_test.go @@ -359,6 +359,85 @@ 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: ``, + wantHint: true, + hintContains: `background-color="light-yellow"`, + }, + { + name: "info type without background-color emits hint", + input: ``, + wantHint: true, + hintContains: `background-color="light-blue"`, + }, + { + name: "single-quoted type attribute emits hint", + input: ``, + wantHint: true, + hintContains: `background-color="light-yellow"`, + }, + { + name: "explicit background-color suppresses hint", + input: ``, + wantHint: false, + }, + { + name: "whitespace around equals is tolerated in background-color", + input: ``, + wantHint: false, + }, + { + name: "unknown type emits no hint", + input: ``, + wantHint: false, + }, + { + name: "no type attribute emits no hint", + input: ``, + wantHint: false, + }, + { + name: "non-callout tag emits no hint", + input: `
`, + wantHint: false, + }, + { + name: "hint includes border-color suggestion", + input: ``, + wantHint: true, + hintContains: `border-color="red"`, + }, + } + 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