Skip to content

Commit cfd89e0

Browse files
authored
feat(doc): warn when callout uses type= without background-color (#467)
* feat(doc): expand callout type= shorthand into background-color and border-color When users write <callout type="warning" emoji="📝"> 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. * refactor(doc): replace silent callout type→color injection with hint output 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"). * fix(doc): harden background-color detection in WarnCalloutType CodeRabbit flagged that the previous strings.Contains(attrs, "background-color=") check missed forms like 'background-color = "light-red"' with whitespace around the equals sign. Replace with a regex that tolerates optional whitespace, and add a regression test. * fix(doc): close real review gaps left over after rebase PR #467's review thread had three substantive comments (`fangshuyu-768`, 2026-04-21) that the prior reply messages claimed were fixed in commit 7d4b556 — but that commit no longer exists on the branch (lost in a rebase / squash), and the head still ships the original buggy code. This commit makes the fixes real. Three behavior fixes in shortcuts/doc/markdown_fix.go: 1. (#5) Tighten the type= and background-color= regex anchors. \b sits at any word/non-word boundary, and `-` is a non-word char, so `\btype=` also matched the suffix of `data-type=` — a tag like `<callout data-type="warning">` would emit a bogus light-yellow hint. Switched both regexes to `(?:^|\s)…` so a real attribute separator is required. The same anchor on background-color closes the symmetric case where a `data-background-color=` attribute would silently suppress the real hint. 2. (#4) WarnCalloutType is now a fence-aware line walker. Previously the regex ran over the entire markdown body, so a callout sample inside a documentation code fence (```markdown … ```) would generate a phantom stderr hint every time the docs mentioned the feature. The walker tracks fence state via the existing codeFenceOpenMarker / isCodeFenceClose helpers from docs_update_check.go, which handle both backtick and tilde fences per CommonMark §4.5. 3. (#3) Drop the ReplaceAllStringFunc-as-iterator pattern. The previous code routed callout iteration through a rewrite primitive whose rebuilt-string return value was discarded, then ran the same regex a second time inside the callback to recover the capture groups. New scanCalloutTagsForWarning helper uses FindAllStringSubmatch — one pass, no thrown-away allocation, intent matches the surface (read-only scan, not a mutator). Tests: 5 new TestWarnCalloutType subtests pin each contract: - data-type attribute does not trigger hint (#5) - data-background-color does not suppress hint (#5, symmetric) - callout inside backtick fence emits no hint (#4) - callout inside tilde fence emits no hint (#4) - callout after fence close still emits hint (#4, fence-state reset) All 14 TestWarnCalloutType cases pass; go vet / golangci-lint --new-from-rev=origin/main both clean.
1 parent ac4c34f commit cfd89e0

4 files changed

Lines changed: 251 additions & 1 deletion

File tree

shortcuts/doc/docs_create.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.D
111111

112112
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
113113
warnDeprecatedV1(runtime, "+create")
114+
// Surface callout type= hint so users know to switch to background-color/
115+
// border-color when they want a colored callout. Non-blocking, advisory.
116+
if md := runtime.Str("markdown"); md != "" {
117+
WarnCalloutType(md, runtime.IO().ErrOut)
118+
}
114119
args := buildCreateArgsV1(runtime)
115120
result, err := common.CallMCPTool(runtime, "create-doc", args)
116121
if err != nil {
@@ -123,8 +128,9 @@ func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
123128
}
124129

125130
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
131+
md := runtime.Str("markdown")
126132
args := map[string]interface{}{
127-
"markdown": runtime.Str("markdown"),
133+
"markdown": md,
128134
}
129135
if v := runtime.Str("title"); v != "" {
130136
args["title"] = v

shortcuts/doc/docs_update.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
160160
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
161161
}
162162

163+
// Surface callout type= hint so users know to switch to background-color/
164+
// border-color when they want a colored callout. Non-blocking, advisory.
165+
if md := runtime.Str("markdown"); md != "" {
166+
WarnCalloutType(md, runtime.IO().ErrOut)
167+
}
168+
163169
args := buildUpdateArgsV1(runtime)
164170

165171
result, err := common.CallMCPTool(runtime, "update-doc", args)

shortcuts/doc/markdown_fix.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package doc
55

66
import (
7+
"fmt"
8+
"io"
79
"regexp"
810
"strings"
911
"unicode"
@@ -306,6 +308,113 @@ func fixSetextAmbiguity(md string) string {
306308
return setextRe.ReplaceAllString(md, "$1\n\n$2")
307309
}
308310

311+
// calloutTypeColors maps the semantic type= shorthand to a recommended
312+
// [background-color, border-color] pair for Feishu callout blocks.
313+
// Used only for hint messages — the Markdown itself is never rewritten.
314+
var calloutTypeColors = map[string][2]string{
315+
"warning": {"light-yellow", "yellow"},
316+
"caution": {"light-orange", "orange"},
317+
"note": {"light-blue", "blue"},
318+
"info": {"light-blue", "blue"},
319+
"tip": {"light-green", "green"},
320+
"success": {"light-green", "green"},
321+
"check": {"light-green", "green"},
322+
"error": {"light-red", "red"},
323+
"danger": {"light-red", "red"},
324+
"important": {"light-purple", "purple"},
325+
}
326+
327+
// calloutOpenTagRe matches a <callout …> opening tag.
328+
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
329+
330+
// calloutTypeAttrRe extracts the value of a type= attribute (single or
331+
// double quoted) from a callout opening tag's attribute string. The
332+
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
333+
// word/non-word boundary, and `-` is a non-word character, so
334+
// `\btype=` would also match the suffix of `data-type=` and yield a
335+
// bogus type lookup. Anchoring on start-of-string-or-whitespace
336+
// requires a real attribute separator before the name.
337+
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
338+
339+
// calloutBackgroundColorAttrRe matches a background-color= attribute
340+
// name with optional whitespace around the equals sign, so forms like
341+
// `background-color="..."` and `background-color = "..."` are both
342+
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
343+
// reason: `data-background-color="..."` must not look like a present
344+
// background-color and silently suppress the hint.
345+
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
346+
347+
// WarnCalloutType scans md for callout tags that carry a type= attribute but
348+
// no background-color= attribute, then writes a hint line to w for each one
349+
// suggesting the explicit Feishu color attributes to use instead.
350+
//
351+
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
352+
// are documentation samples, not real callouts the user wants Feishu to
353+
// render. Fence detection uses the shared codeFenceOpenMarker /
354+
// isCodeFenceClose helpers so both backtick and tilde fences are handled
355+
// (matching CommonMark §4.5).
356+
//
357+
// The Markdown is not modified — the caller is responsible for acting on
358+
// the hints or ignoring them. This keeps the create/update path
359+
// transparent: user input reaches create-doc exactly as written.
360+
func WarnCalloutType(md string, w io.Writer) {
361+
fenceMarker := ""
362+
for _, line := range strings.Split(md, "\n") {
363+
if fenceMarker != "" {
364+
// Inside a fenced block — skip everything until the matching
365+
// closer. Code samples that show literal <callout type=...>
366+
// must not produce a phantom hint.
367+
if isCodeFenceClose(line, fenceMarker) {
368+
fenceMarker = ""
369+
}
370+
continue
371+
}
372+
if marker := codeFenceOpenMarker(line); marker != "" {
373+
fenceMarker = marker
374+
continue
375+
}
376+
scanCalloutTagsForWarning(line, w)
377+
}
378+
}
379+
380+
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
381+
// tag in s that lacks an explicit background-color= attribute. Pulled out
382+
// of WarnCalloutType so the line walker only handles fence state and the
383+
// per-tag scan is its own readable unit.
384+
//
385+
// The previous implementation routed the tag iteration through
386+
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
387+
// returned the original tag and threw the rebuilt string away — using a
388+
// rewrite primitive purely for its iteration side-effect, plus a second
389+
// regex execution to recover the capture groups inside the callback.
390+
// FindAllStringSubmatch hands us both the iteration and the groups in one
391+
// pass, no allocation thrown away.
392+
func scanCalloutTagsForWarning(s string, w io.Writer) {
393+
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
394+
attrs := m[1]
395+
// Skip tags that already carry an explicit background-color.
396+
if calloutBackgroundColorAttrRe.MatchString(attrs) {
397+
continue
398+
}
399+
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
400+
if len(parts) < 3 {
401+
continue // no type= attribute
402+
}
403+
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
404+
typeName := parts[1]
405+
if typeName == "" {
406+
typeName = parts[2]
407+
}
408+
colors, ok := calloutTypeColors[typeName]
409+
if !ok {
410+
continue // unknown type — no hint to give
411+
}
412+
fmt.Fprintf(w,
413+
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
414+
typeName, colors[0], colors[1])
415+
}
416+
}
417+
309418
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
310419
// Unicode emoji characters that create-doc accepts.
311420
var calloutEmojiAliases = map[string]string{

shortcuts/doc/markdown_fix_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,135 @@ func TestFixExportedMarkdown(t *testing.T) {
359359
}
360360
}
361361

362+
func TestWarnCalloutType(t *testing.T) {
363+
tests := []struct {
364+
name string
365+
input string
366+
wantHint bool // whether a hint line is expected
367+
hintContains string // substring the hint must contain
368+
}{
369+
{
370+
name: "warning type without background-color emits hint",
371+
input: `<callout type="warning" emoji="📝">`,
372+
wantHint: true,
373+
hintContains: `background-color="light-yellow"`,
374+
},
375+
{
376+
name: "info type without background-color emits hint",
377+
input: `<callout type="info" emoji="ℹ️">`,
378+
wantHint: true,
379+
hintContains: `background-color="light-blue"`,
380+
},
381+
{
382+
name: "single-quoted type attribute emits hint",
383+
input: `<callout type='warning' emoji="📝">`,
384+
wantHint: true,
385+
hintContains: `background-color="light-yellow"`,
386+
},
387+
{
388+
name: "explicit background-color suppresses hint",
389+
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
390+
wantHint: false,
391+
},
392+
{
393+
name: "whitespace around equals is tolerated in background-color",
394+
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
395+
wantHint: false,
396+
},
397+
{
398+
name: "unknown type emits no hint",
399+
input: `<callout type="custom" emoji="🔥">`,
400+
wantHint: false,
401+
},
402+
{
403+
name: "no type attribute emits no hint",
404+
input: `<callout emoji="💡" background-color="light-green">`,
405+
wantHint: false,
406+
},
407+
{
408+
name: "non-callout tag emits no hint",
409+
input: `<div type="warning">`,
410+
wantHint: false,
411+
},
412+
{
413+
name: "hint includes border-color suggestion",
414+
input: `<callout type="error" emoji="❌">`,
415+
wantHint: true,
416+
hintContains: `border-color="red"`,
417+
},
418+
{
419+
// Regression: the old `\btype=` regex matched the suffix of
420+
// `data-type=` because `-` is a non-word character, so a tag
421+
// carrying only data-attrs would silently get a bogus hint.
422+
// The (?:^|\s) anchor requires a real attribute separator.
423+
name: "data-type attribute does not trigger hint",
424+
input: `<callout data-type="warning" emoji="📝">`,
425+
wantHint: false,
426+
},
427+
{
428+
// Symmetric guard for the background-color regex: a future
429+
// `data-background-color=` attribute must not be mistaken
430+
// for a present background-color and silently suppress the
431+
// hint that the real type= would otherwise produce.
432+
name: "data-background-color does not suppress hint",
433+
input: `<callout type="warning" data-background-color="anything">`,
434+
wantHint: true,
435+
hintContains: `background-color="light-yellow"`,
436+
},
437+
{
438+
// Regression for the code-fence skip: a documentation sample
439+
// inside a ``` fence is NOT a real callout the user wants
440+
// rendered, so it must produce no stderr noise.
441+
name: "callout inside backtick fence emits no hint",
442+
input: "```markdown\n" +
443+
`<callout type="warning" emoji="📝">` + "\n" +
444+
"```\n",
445+
wantHint: false,
446+
},
447+
{
448+
// Same skip works for tilde fences (CommonMark §4.5 makes
449+
// `~~~` an equivalent fence character).
450+
name: "callout inside tilde fence emits no hint",
451+
input: "~~~markdown\n" +
452+
`<callout type="info" emoji="ℹ️">` + "\n" +
453+
"~~~\n",
454+
wantHint: false,
455+
},
456+
{
457+
// Closing the fence must restore normal scanning: a real
458+
// callout that follows a documentation block still gets a
459+
// hint. Pins that fenceMarker is reset, not stuck.
460+
name: "callout after fence close still emits hint",
461+
input: "```markdown\n" +
462+
`<callout type="warning">sample</callout>` + "\n" +
463+
"```\n" +
464+
`<callout type="error" emoji="❌">real</callout>` + "\n",
465+
wantHint: true,
466+
hintContains: `border-color="red"`,
467+
},
468+
}
469+
for _, tt := range tests {
470+
t.Run(tt.name, func(t *testing.T) {
471+
var buf strings.Builder
472+
WarnCalloutType(tt.input, &buf)
473+
got := buf.String()
474+
if tt.wantHint {
475+
if got == "" {
476+
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
477+
return
478+
}
479+
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
480+
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
481+
}
482+
} else {
483+
if got != "" {
484+
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
485+
}
486+
}
487+
})
488+
}
489+
}
490+
362491
func TestFixCalloutEmoji(t *testing.T) {
363492
tests := []struct {
364493
name string

0 commit comments

Comments
 (0)