From a59a0d0e6eee5c765845fcd84427e47676e087be Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 14 Apr 2026 18:00:48 +0800 Subject: [PATCH 1/2] feat(doc): expand callout type= shorthand into background-color and border-color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users write without an explicit background-color, the Feishu doc renders the block with no color. This commit adds fixCalloutType() which maps the semantic type= attribute to the corresponding background-color/border-color pair accepted by create-doc. - warning β†’ light-yellow/yellow - info/note β†’ light-blue/blue - tip/success/check β†’ light-green/green - error/danger β†’ light-red/red - caution β†’ light-orange/orange - important β†’ light-purple/purple Explicit background-color or border-color attributes are always preserved. The fix is applied via prepareMarkdownForCreate() in both +create and +update paths, and also inside fixExportedMarkdown() for round-trip fidelity. Co-Authored-By: Claude Sonnet 4.6 --- shortcuts/doc/docs_create.go | 2 +- shortcuts/doc/docs_update.go | 4 +- shortcuts/doc/markdown_fix.go | 63 ++++++++++++++++++++++++++ shortcuts/doc/markdown_fix_test.go | 72 ++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 69ec15c8..7f7b94b1 100644 --- a/shortcuts/doc/docs_create.go +++ b/shortcuts/doc/docs_create.go @@ -68,7 +68,7 @@ var DocsCreate = common.Shortcut{ func buildDocsCreateArgs(runtime *common.RuntimeContext) map[string]interface{} { args := map[string]interface{}{ - "markdown": runtime.Str("markdown"), + "markdown": prepareMarkdownForCreate(runtime.Str("markdown")), } if v := runtime.Str("title"); v != "" { args["title"] = v diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index ea80550e..13d7b0ba 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -71,7 +71,7 @@ var DocsUpdate = common.Shortcut{ "mode": runtime.Str("mode"), } if v := runtime.Str("markdown"); v != "" { - args["markdown"] = v + args["markdown"] = prepareMarkdownForCreate(v) } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v @@ -94,7 +94,7 @@ var DocsUpdate = common.Shortcut{ "mode": runtime.Str("mode"), } if v := runtime.Str("markdown"); v != "" { - args["markdown"] = v + args["markdown"] = prepareMarkdownForCreate(v) } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v diff --git a/shortcuts/doc/markdown_fix.go b/shortcuts/doc/markdown_fix.go index f9b28dcc..2ac98b69 100644 --- a/shortcuts/doc/markdown_fix.go +++ b/shortcuts/doc/markdown_fix.go @@ -31,12 +31,21 @@ import ( // 5. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with // actual Unicode emoji characters that create-doc understands. Applied only // outside fenced code blocks. +// +// prepareMarkdownForCreate applies fixes that should run on any Markdown before +// it is sent to the create-doc / update-doc MCP tools, regardless of whether +// the content originated from a Lark export or was written by hand. +func prepareMarkdownForCreate(md string) string { + return applyOutsideCodeFences(md, fixCalloutType) +} + func fixExportedMarkdown(md string) string { md = applyOutsideCodeFences(md, fixBoldSpacing) md = applyOutsideCodeFences(md, fixSetextAmbiguity) md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks) md = fixTopLevelSoftbreaks(md) md = applyOutsideCodeFences(md, fixCalloutEmoji) + md = applyOutsideCodeFences(md, fixCalloutType) // Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line), // but only outside fenced code blocks to preserve intentional blank lines in code. md = applyOutsideCodeFences(md, func(s string) string { @@ -220,6 +229,60 @@ func fixSetextAmbiguity(md string) string { return setextRe.ReplaceAllString(md, "$1\n\n$2") } +// calloutTypeColors maps callout type="" to a [background-color, border-color] pair. +// When a callout tag has type= but no background-color=, these defaults are applied. +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"}, +} + +// calloutTypeRe matches a opening tag so individual attributes can +// be inspected and patched. +var calloutTypeRe = regexp.MustCompile(`]*)?>`) + +// fixCalloutType expands the semantic type="" shorthand on callout tags +// into explicit background-color= (and border-color= when absent). When a +// background-color is already present the tag is left unchanged so that an +// explicit color always wins. +func fixCalloutType(md string) string { + return calloutTypeRe.ReplaceAllStringFunc(md, func(tag string) string { + attrs := "" + if m := calloutTypeRe.FindStringSubmatch(tag); len(m) == 2 { + attrs = m[1] + } + // Extract type value. + typeRe := regexp.MustCompile(`\btype="([^"]*)"`) + typeParts := typeRe.FindStringSubmatch(attrs) + if len(typeParts) != 2 { + return tag // no type= attribute + } + typeName := typeParts[1] + colors, ok := calloutTypeColors[typeName] + if !ok { + return tag // unknown type β€” leave as-is + } + // Only inject background-color when it is absent. + if strings.Contains(attrs, "background-color=") { + return tag + } + // Inject background-color (and border-color when absent) before the + // closing >. + extra := ` background-color="` + colors[0] + `"` + if !strings.Contains(attrs, "border-color=") { + extra += ` border-color="` + colors[1] + `"` + } + return tag[:len(tag)-1] + extra + ">" + }) +} + // 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 b47ab778..b76b181e 100644 --- a/shortcuts/doc/markdown_fix_test.go +++ b/shortcuts/doc/markdown_fix_test.go @@ -252,6 +252,78 @@ func TestFixExportedMarkdown(t *testing.T) { } } +func TestFixCalloutType(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "warning type gets light-yellow background and border", + input: ``, + want: ``, + }, + { + name: "info type gets light-blue", + input: ``, + want: ``, + }, + { + name: "tip type gets light-green", + input: ``, + want: ``, + }, + { + name: "error type gets light-red", + input: ``, + want: ``, + }, + { + name: "important type gets light-purple", + input: ``, + want: ``, + }, + { + name: "caution type gets light-orange", + input: ``, + want: ``, + }, + { + name: "explicit background-color is preserved", + input: ``, + want: ``, + }, + { + name: "explicit border-color is preserved when background-color absent", + input: ``, + want: ``, + }, + { + name: "unknown type left unchanged", + input: ``, + want: ``, + }, + { + name: "no type attribute left unchanged", + input: ``, + want: ``, + }, + { + name: "non-callout tag unchanged", + input: `
`, + want: `
`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fixCalloutType(tt.input) + if got != tt.want { + t.Errorf("fixCalloutType(%q)\n got %q\n want %q", tt.input, got, tt.want) + } + }) + } +} + func TestFixCalloutEmoji(t *testing.T) { tests := []struct { name string From 011da3d0f19e1ebfb498e884c0b00788a494f9ae Mon Sep 17 00:00:00 2001 From: baiqing Date: Wed, 15 Apr 2026 11:34:27 +0800 Subject: [PATCH 2/2] =?UTF-8?q?refactor(doc):=20replace=20silent=20callout?= =?UTF-8?q?=20type=E2=86=92color=20injection=20with=20hint=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per reviewer feedback (SunPeiYang996), silently rewriting user Markdown is the wrong layer for this adaptation. The type→color mapping is not part of the Feishu spec, and covert transforms make debugging harder. Replace fixCalloutType() (which rewrote the Markdown) with WarnCalloutType() which leaves the Markdown unchanged and instead writes a hint line to stderr for each callout tag that has type= but no background-color, telling the user the recommended explicit attributes to add: hint: callout type="warning" has no background-color; consider: background-color="light-yellow" border-color="yellow" Also fixes CodeRabbit feedback: the type= regex now accepts both single-quoted and double-quoted attribute values (type='warning' and type="warning"). Co-Authored-By: Claude Sonnet 4.6 --- shortcuts/doc/docs_create.go | 4 +- shortcuts/doc/docs_update.go | 6 +- shortcuts/doc/markdown_fix.go | 75 ++++++++++++------------ shortcuts/doc/markdown_fix_test.go | 94 +++++++++++++++--------------- 4 files changed, 92 insertions(+), 87 deletions(-) diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 7f7b94b1..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": prepareMarkdownForCreate(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 13d7b0ba..1c396bf9 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -71,7 +71,8 @@ var DocsUpdate = common.Shortcut{ "mode": runtime.Str("mode"), } if v := runtime.Str("markdown"); v != "" { - args["markdown"] = prepareMarkdownForCreate(v) + WarnCalloutType(v, runtime.IO().ErrOut) + args["markdown"] = v } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v @@ -94,7 +95,8 @@ var DocsUpdate = common.Shortcut{ "mode": runtime.Str("mode"), } if v := runtime.Str("markdown"); v != "" { - args["markdown"] = prepareMarkdownForCreate(v) + WarnCalloutType(v, runtime.IO().ErrOut) + args["markdown"] = v } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v diff --git a/shortcuts/doc/markdown_fix.go b/shortcuts/doc/markdown_fix.go index 2ac98b69..a4f5e313 100644 --- a/shortcuts/doc/markdown_fix.go +++ b/shortcuts/doc/markdown_fix.go @@ -4,6 +4,8 @@ package doc import ( + "fmt" + "io" "regexp" "strings" ) @@ -31,21 +33,12 @@ import ( // 5. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with // actual Unicode emoji characters that create-doc understands. Applied only // outside fenced code blocks. -// -// prepareMarkdownForCreate applies fixes that should run on any Markdown before -// it is sent to the create-doc / update-doc MCP tools, regardless of whether -// the content originated from a Lark export or was written by hand. -func prepareMarkdownForCreate(md string) string { - return applyOutsideCodeFences(md, fixCalloutType) -} - func fixExportedMarkdown(md string) string { md = applyOutsideCodeFences(md, fixBoldSpacing) md = applyOutsideCodeFences(md, fixSetextAmbiguity) md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks) md = fixTopLevelSoftbreaks(md) md = applyOutsideCodeFences(md, fixCalloutEmoji) - md = applyOutsideCodeFences(md, fixCalloutType) // Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line), // but only outside fenced code blocks to preserve intentional blank lines in code. md = applyOutsideCodeFences(md, func(s string) string { @@ -229,8 +222,9 @@ func fixSetextAmbiguity(md string) string { return setextRe.ReplaceAllString(md, "$1\n\n$2") } -// calloutTypeColors maps callout type="" to a [background-color, border-color] pair. -// When a callout tag has type= but no background-color=, these defaults are applied. +// 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"}, @@ -244,42 +238,47 @@ var calloutTypeColors = map[string][2]string{ "important": {"light-purple", "purple"}, } -// calloutTypeRe matches a opening tag so individual attributes can -// be inspected and patched. -var calloutTypeRe = regexp.MustCompile(`]*)?>`) +// 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=(?:"([^"]*)"|'([^']*)')`) -// fixCalloutType expands the semantic type="" shorthand on callout tags -// into explicit background-color= (and border-color= when absent). When a -// background-color is already present the tag is left unchanged so that an -// explicit color always wins. -func fixCalloutType(md string) string { - return calloutTypeRe.ReplaceAllStringFunc(md, func(tag string) string { +// 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 := calloutTypeRe.FindStringSubmatch(tag); len(m) == 2 { + if m := calloutOpenTagRe.FindStringSubmatch(tag); len(m) == 2 { attrs = m[1] } - // Extract type value. - typeRe := regexp.MustCompile(`\btype="([^"]*)"`) - typeParts := typeRe.FindStringSubmatch(attrs) - if len(typeParts) != 2 { + // Skip tags that already carry an explicit background-color. + if strings.Contains(attrs, "background-color=") { + return tag + } + parts := calloutTypeAttrRe.FindStringSubmatch(attrs) + if len(parts) < 2 { return tag // no type= attribute } - typeName := typeParts[1] + // 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 — leave as-is - } - // Only inject background-color when it is absent. - if strings.Contains(attrs, "background-color=") { - return tag - } - // Inject background-color (and border-color when absent) before the - // closing >. - extra := ` background-color="` + colors[0] + `"` - if !strings.Contains(attrs, "border-color=") { - extra += ` border-color="` + colors[1] + `"` + return tag // unknown type — no hint to give } - return tag[:len(tag)-1] + extra + ">" + 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 }) } diff --git a/shortcuts/doc/markdown_fix_test.go b/shortcuts/doc/markdown_fix_test.go index b76b181e..641b0679 100644 --- a/shortcuts/doc/markdown_fix_test.go +++ b/shortcuts/doc/markdown_fix_test.go @@ -252,73 +252,75 @@ func TestFixExportedMarkdown(t *testing.T) { } } -func TestFixCalloutType(t *testing.T) { +func TestWarnCalloutType(t *testing.T) { tests := []struct { - name string - input string - want string + name string + input string + wantHint bool // whether a hint line is expected + hintContains string // substring the hint must contain }{ { - name: "warning type gets light-yellow background and border", - input: ``, - want: ``, + name: "warning type without background-color emits hint", + input: ``, + wantHint: true, + hintContains: `background-color="light-yellow"`, }, { - name: "info type gets light-blue", - input: ``, - want: ``, + name: "info type without background-color emits hint", + input: ``, + wantHint: true, + hintContains: `background-color="light-blue"`, }, { - name: "tip type gets light-green", - input: ``, - want: ``, + name: "single-quoted type attribute emits hint", + input: ``, + wantHint: true, + hintContains: `background-color="light-yellow"`, }, { - name: "error type gets light-red", - input: ``, - want: ``, + name: "explicit background-color suppresses hint", + input: ``, + wantHint: false, }, { - name: "important type gets light-purple", - input: ``, - want: ``, + name: "unknown type emits no hint", + input: ``, + wantHint: false, }, { - name: "caution type gets light-orange", - input: ``, - want: ``, + name: "no type attribute emits no hint", + input: ``, + wantHint: false, }, { - name: "explicit background-color is preserved", - input: ``, - want: ``, + name: "non-callout tag emits no hint", + input: `
`, + wantHint: false, }, { - name: "explicit border-color is preserved when background-color absent", - input: ``, - want: ``, - }, - { - name: "unknown type left unchanged", - input: ``, - want: ``, - }, - { - name: "no type attribute left unchanged", - input: ``, - want: ``, - }, - { - name: "non-callout tag unchanged", - input: `
`, - want: `
`, + 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) { - got := fixCalloutType(tt.input) - if got != tt.want { - t.Errorf("fixCalloutType(%q)\n got %q\n want %q", tt.input, got, tt.want) + 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) + } } }) }