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 6f6b1fef..c495ed5f 100644 --- a/shortcuts/doc/docs_update.go +++ b/shortcuts/doc/docs_update.go @@ -72,6 +72,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 != "" { @@ -105,6 +106,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..7e88e934 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,108 @@ 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. +// +// 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 ``. +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. +// +// 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) { + 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 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 + } + // 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{ diff --git a/shortcuts/doc/markdown_fix_test.go b/shortcuts/doc/markdown_fix_test.go index 81ac26a9..82841510 100644 --- a/shortcuts/doc/markdown_fix_test.go +++ b/shortcuts/doc/markdown_fix_test.go @@ -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: ``, + 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"`, + }, + { + // 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: ``, + 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\ntext\n```", + wantHint: false, + }, + { + // Callout samples inside tilde fence also skipped. + name: "callout tag inside tilde fenced code block is ignored", + input: "example:\n~~~\nhi\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: real\n```\nsample\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