From e0aed5cd47520684b53cf2e51b853fa3db3d9259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Fri, 24 Apr 2026 18:59:52 +0800 Subject: [PATCH 1/7] im: normalize malformed markdown emphasis spacing Change-Id: I0e94087876bd20869cf69beffc4b69ffd26f5040 --- shortcuts/im/helpers.go | 156 +++++++++++++++++++++++++++++++++++ shortcuts/im/helpers_test.go | 18 ++++ 2 files changed, 174 insertions(+) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 9086b0688..5fb610dc0 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -18,6 +18,8 @@ import ( "regexp" "strconv" "strings" + "unicode" + "unicode/utf8" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" @@ -818,6 +820,7 @@ func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string { func optimizeMarkdownStyle(text string) string { r, codeBlocks := protectMarkdownCodeBlocks(text) + r = normalizeMarkdownEmphasisSpacing(r) // Only downgrade when original text has H1~H3; order matters (H2~H6 first). if reHasH1toH3.MatchString(text) { @@ -849,6 +852,159 @@ func optimizeMarkdownStyle(text string) string { return r } +// normalizeMarkdownEmphasisSpacing trims whitespace immediately inside simple +// *...* and **...** spans while preserving fenced code blocks and inline code. +// This hardens AI-generated markdown such as "** bold **" into "**bold**" so +// Feishu's md renderer can recognize emphasis instead of leaking literal '*'. +func normalizeMarkdownEmphasisSpacing(markdown string) string { + lines := strings.Split(markdown, "\n") + inFence := false + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + if inFence { + continue + } + lines[i] = normalizeMarkdownEmphasisSpacingLine(line) + } + return strings.Join(lines, "\n") +} + +// scanInlineCodeSpans returns byte ranges [start, end) for inline code spans +// using matching backtick runs, so emphasis normalization skips literal code. +func scanInlineCodeSpans(line string) [][2]int { + var spans [][2]int + i := 0 + for i < len(line) { + if line[i] != '`' { + i++ + continue + } + start := i + for i < len(line) && line[i] == '`' { + i++ + } + delim := line[start:i] + j := i + for j <= len(line)-len(delim) { + if line[j] == '`' { + k := j + for k < len(line) && line[k] == '`' { + k++ + } + if k-j == len(delim) { + spans = append(spans, [2]int{start, k}) + i = k + break + } + j = k + } else { + j++ + } + } + } + return spans +} + +func normalizeMarkdownEmphasisSpacingLine(line string) string { + spans := scanInlineCodeSpans(line) + if len(spans) == 0 { + return normalizeEmphasisSpacingSegment(line) + } + var sb strings.Builder + pos := 0 + for _, loc := range spans { + sb.WriteString(normalizeEmphasisSpacingSegment(line[pos:loc[0]])) + sb.WriteString(line[loc[0]:loc[1]]) + pos = loc[1] + } + sb.WriteString(normalizeEmphasisSpacingSegment(line[pos:])) + return sb.String() +} + +func normalizeEmphasisSpacingSegment(seg string) string { + if !strings.Contains(seg, "*") { + return seg + } + + var sb strings.Builder + pos := 0 + for pos < len(seg) { + openStart, openEnd, ok := nextAsteriskRun(seg, pos) + if !ok { + sb.WriteString(seg[pos:]) + break + } + + sb.WriteString(seg[pos:openStart]) + + markerLen := openEnd - openStart + if markerLen != 1 && markerLen != 2 { + sb.WriteString(seg[openStart:openEnd]) + pos = openEnd + continue + } + + closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd) + if !ok || closeEnd-closeStart != markerLen { + sb.WriteString(seg[openStart:openEnd]) + pos = openEnd + continue + } + + payload := seg[openEnd:closeStart] + normalized, shouldNormalize := normalizeEmphasisPayload(payload) + if !shouldNormalize { + sb.WriteString(seg[openStart:closeEnd]) + pos = closeEnd + continue + } + + marker := seg[openStart:openEnd] + sb.WriteString(marker) + sb.WriteString(normalized) + sb.WriteString(marker) + pos = closeEnd + } + return sb.String() +} + +func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) { + for i := start; i < len(s); i++ { + if s[i] != '*' { + continue + } + j := i + for j < len(s) && s[j] == '*' { + j++ + } + return i, j, true + } + return 0, 0, false +} + +func normalizeEmphasisPayload(payload string) (string, bool) { + trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace) + trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace) + if trimmed == "" { + return payload, false + } + + hasLeadingSpace := len(trimmedLeft) != len(payload) + hasTrailingSpace := len(trimmed) != len(trimmedLeft) + if !hasLeadingSpace && !hasTrailingSpace { + return payload, true + } + + if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 { + return payload, false + } + return trimmed, true +} + func shouldUseSegmentedPost(markdown string) bool { protected, _ := protectMarkdownCodeBlocks(markdown) return reBlankLineSeparator.MatchString(protected) diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 0bb39049f..99b61e20f 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -519,6 +519,24 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) + t.Run("normalizes malformed bold spacing", func(t *testing.T) { + got := wrapMarkdownAsPost("hello ** world **") + node := decodePostParagraphForTest(t, got, 0) + if node["text"] != "hello **world**" { + t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "hello **world**") + } + }) + + t.Run("preserves inline code and fenced code spacing", func(t *testing.T) { + input := "code `** keep **` and prose ** fix **\n```md\n** keep fenced **\n```" + got := wrapMarkdownAsPost(input) + node := decodePostParagraphForTest(t, got, 0) + text, _ := node["text"].(string) + if text != "code `** keep **` and prose **fix**\n```md\n** keep fenced **\n```" { + t.Fatalf("wrapMarkdownAsPost() text = %#v", node["text"]) + } + }) + t.Run("bare URL becomes a tag", func(t *testing.T) { got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done") if !strings.Contains(got, `"tag":"a"`) { From 7197b5e164a988b01c0793bef33d2e96cc116c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 17:29:51 +0800 Subject: [PATCH 2/7] docs(im): tighten markdown emphasis guidance Change-Id: Ic8700ad015f459d0e95e4341224ce70855f94fe4 --- skills/lark-im/references/lark-im-messages-reply.md | 5 +++++ skills/lark-im/references/lark-im-messages-send.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/skills/lark-im/references/lark-im-messages-reply.md b/skills/lark-im/references/lark-im-messages-reply.md index cc73a5993..c3ca47d28 100644 --- a/skills/lark-im/references/lark-im-messages-reply.md +++ b/skills/lark-im/references/lark-im-messages-reply.md @@ -35,6 +35,11 @@ When using `--as user`, the reply is sent as the authorized end user and require - Use `--markdown` when you want a lightweight formatted reply and you accept that the shortcut will normalize and rewrite parts of the content before sending. - Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably. +### If you choose `--markdown`, write emphasis without inner edge spaces + +- `**bold**`, not `** bold **` +- `*italic*`, not `* italic *` + ## What `--markdown` Really Does `--markdown` does **not** send arbitrary raw Markdown to the API. diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index a328063a3..d9aab835e 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -35,6 +35,11 @@ When using `--as user`, the message is sent as the authorized end user and requi - Use `--markdown` when you want basic Markdown-style rendering and you accept that the shortcut will normalize and rewrite parts of the content before sending. - Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures. +### If you choose `--markdown`, write emphasis without inner edge spaces + +- `**bold**`, not `** bold **` +- `*italic*`, not `* italic *` + ## What `--markdown` Really Does `--markdown` is **not** sent as raw Markdown API content. From efc50f7f73dfcec14b29872049bdfff84b18e069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 18:01:39 +0800 Subject: [PATCH 3/7] fix(im): narrow markdown emphasis spacing normalization Change-Id: Ic2609ca45653b48e21c303d2e41deb0902870bf4 --- shortcuts/im/helpers.go | 52 +++++++++++++++++++++++++++++++----- shortcuts/im/helpers_test.go | 16 +++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 5fb610dc0..ef252add3 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -853,9 +853,10 @@ func optimizeMarkdownStyle(text string) string { } // normalizeMarkdownEmphasisSpacing trims whitespace immediately inside simple -// *...* and **...** spans while preserving fenced code blocks and inline code. -// This hardens AI-generated markdown such as "** bold **" into "**bold**" so -// Feishu's md renderer can recognize emphasis instead of leaking literal '*'. +// *...*, **...**, and ***...*** spans while preserving fenced code blocks and +// inline code. This hardens AI-generated markdown such as "** bold **" into +// "**bold**" so Feishu's md renderer can recognize emphasis instead of +// leaking literal '*'. func normalizeMarkdownEmphasisSpacing(markdown string) string { lines := strings.Split(markdown, "\n") inFence := false @@ -942,7 +943,7 @@ func normalizeEmphasisSpacingSegment(seg string) string { sb.WriteString(seg[pos:openStart]) markerLen := openEnd - openStart - if markerLen != 1 && markerLen != 2 { + if markerLen != 1 && markerLen != 2 && markerLen != 3 { sb.WriteString(seg[openStart:openEnd]) pos = openEnd continue @@ -954,6 +955,11 @@ func normalizeEmphasisSpacingSegment(seg string) string { pos = openEnd continue } + if !hasSimpleEmphasisBoundaries(seg, openStart, closeEnd) { + sb.WriteString(seg[openStart:closeEnd]) + pos = closeEnd + continue + } payload := seg[openEnd:closeStart] normalized, shouldNormalize := normalizeEmphasisPayload(payload) @@ -986,6 +992,26 @@ func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) { return 0, 0, false } +func hasSimpleEmphasisBoundaries(s string, openStart, closeEnd int) bool { + if openStart > 0 { + prev, _ := utf8.DecodeLastRuneInString(s[:openStart]) + if isWordLikeRune(prev) { + return false + } + } + if closeEnd < len(s) { + next, _ := utf8.DecodeRuneInString(s[closeEnd:]) + if isWordLikeRune(next) { + return false + } + } + return true +} + +func isWordLikeRune(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) +} + func normalizeEmphasisPayload(payload string) (string, bool) { trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace) trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace) @@ -996,13 +1022,25 @@ func normalizeEmphasisPayload(payload string) (string, bool) { hasLeadingSpace := len(trimmedLeft) != len(payload) hasTrailingSpace := len(trimmed) != len(trimmedLeft) if !hasLeadingSpace && !hasTrailingSpace { - return payload, true + return payload, false + } + if strings.Contains(trimmed, "*") { + return payload, false } - if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 { return payload, false } - return trimmed, true + first, _ := utf8.DecodeRuneInString(trimmed) + last, _ := utf8.DecodeLastRuneInString(trimmed) + if !isWordLikeRune(first) || !isWordLikeRune(last) { + return payload, false + } + for _, r := range trimmed { + if isWordLikeRune(r) { + return trimmed, true + } + } + return payload, false } func shouldUseSegmentedPost(markdown string) bool { diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 99b61e20f..e7907a019 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -527,6 +527,14 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) + t.Run("normalizes malformed italic and bold italic spacing", func(t *testing.T) { + got := wrapMarkdownAsPost("* italic * and *** both ***") + node := decodePostParagraphForTest(t, got, 0) + if node["text"] != "*italic* and ***both***" { + t.Fatalf("wrapMarkdownAsPost() text = %#v, want %q", node["text"], "*italic* and ***both***") + } + }) + t.Run("preserves inline code and fenced code spacing", func(t *testing.T) { input := "code `** keep **` and prose ** fix **\n```md\n** keep fenced **\n```" got := wrapMarkdownAsPost(input) @@ -537,6 +545,14 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) + t.Run("preserves non emphatic spaced asterisks", func(t *testing.T) { + got := wrapMarkdownAsPost("literal ** /tmp/demo ** and ** x ** and hello** world **there") + node := decodePostParagraphForTest(t, got, 0) + if node["text"] != "literal ** /tmp/demo ** and ** x ** and hello** world **there" { + t.Fatalf("wrapMarkdownAsPost() text = %#v", node["text"]) + } + }) + t.Run("bare URL becomes a tag", func(t *testing.T) { got := wrapMarkdownAsPost("see https://example.com/flow_id=abc_def done") if !strings.Contains(got, `"tag":"a"`) { From 78d037a1639cb371c6fd0edcd400ddec1869255f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 18:43:17 +0800 Subject: [PATCH 4/7] fix(im): relax emphasis spacing guards Change-Id: I0e26a4eda85180de287ebef48d7689583a41a242 --- shortcuts/im/helpers.go | 15 +-------------- shortcuts/im/helpers_test.go | 6 +++--- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index ef252add3..851ab26b0 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -1027,20 +1027,7 @@ func normalizeEmphasisPayload(payload string) (string, bool) { if strings.Contains(trimmed, "*") { return payload, false } - if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 { - return payload, false - } - first, _ := utf8.DecodeRuneInString(trimmed) - last, _ := utf8.DecodeLastRuneInString(trimmed) - if !isWordLikeRune(first) || !isWordLikeRune(last) { - return payload, false - } - for _, r := range trimmed { - if isWordLikeRune(r) { - return trimmed, true - } - } - return payload, false + return trimmed, true } func shouldUseSegmentedPost(markdown string) bool { diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index e7907a019..1b023d05a 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -545,10 +545,10 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) - t.Run("preserves non emphatic spaced asterisks", func(t *testing.T) { - got := wrapMarkdownAsPost("literal ** /tmp/demo ** and ** x ** and hello** world **there") + t.Run("preserves embedded non emphatic spaced asterisks", func(t *testing.T) { + got := wrapMarkdownAsPost("hello** world **there") node := decodePostParagraphForTest(t, got, 0) - if node["text"] != "literal ** /tmp/demo ** and ** x ** and hello** world **there" { + if node["text"] != "hello** world **there" { t.Fatalf("wrapMarkdownAsPost() text = %#v", node["text"]) } }) From 86a9c671e7e7a3dda482f5b8aee044273eebda43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 20:48:07 +0800 Subject: [PATCH 5/7] docs(im): move emphasis spacing note into caveats Change-Id: Ib84e8c31589dc35c29abd8e01b5b8bff1821020a --- skills/lark-im/references/lark-im-messages-reply.md | 6 +----- skills/lark-im/references/lark-im-messages-send.md | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/skills/lark-im/references/lark-im-messages-reply.md b/skills/lark-im/references/lark-im-messages-reply.md index c3ca47d28..45f15a72b 100644 --- a/skills/lark-im/references/lark-im-messages-reply.md +++ b/skills/lark-im/references/lark-im-messages-reply.md @@ -35,11 +35,6 @@ When using `--as user`, the reply is sent as the authorized end user and require - Use `--markdown` when you want a lightweight formatted reply and you accept that the shortcut will normalize and rewrite parts of the content before sending. - Use `--content` when you need exact `post` JSON, a card, a title, multiple locales, or any structure that `--markdown` cannot express reliably. -### If you choose `--markdown`, write emphasis without inner edge spaces - -- `**bold**`, not `** bold **` -- `*italic*`, not `* italic *` - ## What `--markdown` Really Does `--markdown` does **not** send arbitrary raw Markdown to the API. @@ -69,6 +64,7 @@ So `--markdown` is a convenience mode, not a full Markdown compatibility layer. - Block spacing and line breaks may be normalized during conversion. - Code blocks are preserved as code blocks. - Excess blank lines are compressed. +- Emphasis with `*`, `**`, or `***` should not contain inner edge spaces. Use `**bold**` instead of `** bold **`, `** bold**`, or `**bold **`; use `*italic*` instead of `* italic *`, `* italic*`, or `*italic *`. - Only remote `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably. - Local paths in Markdown image syntax like `![x](./a.png)` are **not** auto-uploaded by `--markdown`. - If remote Markdown image handling fails, that image is removed with a warning. diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index d9aab835e..92ed2b66b 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -35,11 +35,6 @@ When using `--as user`, the message is sent as the authorized end user and requi - Use `--markdown` when you want basic Markdown-style rendering and you accept that the shortcut will normalize and rewrite parts of the content before sending. - Use `--content` when `--markdown` is not enough, especially if you need exact `post` JSON, a title, multiple locales, cards, or unsupported rich structures. -### If you choose `--markdown`, write emphasis without inner edge spaces - -- `**bold**`, not `** bold **` -- `*italic*`, not `* italic *` - ## What `--markdown` Really Does `--markdown` is **not** sent as raw Markdown API content. @@ -69,6 +64,7 @@ This means `--markdown` is convenient, but it is not a full-fidelity Markdown tr - Block spacing and line breaks may be normalized during conversion. - Code blocks are preserved as code blocks. - Excess blank lines are compressed. +- Emphasis with `*`, `**`, or `***` should not contain inner edge spaces. Use `**bold**` instead of `** bold **`, `** bold**`, or `**bold **`; use `*italic*` instead of `* italic *`, `* italic*`, or `*italic *`. - Only `http://...`, `https://...`, or already-uploaded `img_xxx` Markdown images are kept reliably. - Local paths in Markdown image syntax like `![x](./a.png)` are **not** auto-uploaded by `--markdown`; they may be stripped during optimization. - If remote Markdown image download/upload fails, that image is removed with a warning. From 9aff324c7a2f394e05b974f8bec052401f0acd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 21:05:03 +0800 Subject: [PATCH 6/7] refactor(im): simplify emphasis spacing normalization Change-Id: Ied3e03e8b3fff21ebf019babb40d3e7eab011c23 --- shortcuts/im/helpers.go | 124 +++++++++++++++++++++++++---------- shortcuts/im/helpers_test.go | 10 +++ 2 files changed, 98 insertions(+), 36 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 851ab26b0..ba0d84306 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -785,13 +785,13 @@ var ( reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`) reExcessNL = regexp.MustCompile(`\n{3,}`) reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) - reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```") reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`) ) const ( - markdownCodeBlockPlaceholder = "___CB_" - postBlankLinePlaceholder = "\u200B" + markdownCodeBlockPlaceholder = "___CB_" + markdownInlineCodePlaceholder = "___IC_" + postBlankLinePlaceholder = "\u200B" ) type markdownPart struct { @@ -801,13 +801,53 @@ type markdownPart struct { } func protectMarkdownCodeBlocks(text string) (string, []string) { + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) var codeBlocks []string - protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { + var block []string + inFence := false + fenceMarker := "" + + flushBlock := func() { idx := len(codeBlocks) - codeBlocks = append(codeBlocks, m) - return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx) - }) - return protected, codeBlocks + codeBlocks = append(codeBlocks, strings.Join(block, "\n")) + out = append(out, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)) + block = nil + } + + for _, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + marker := "" + if strings.HasPrefix(trimmed, "```") { + marker = "```" + } else if strings.HasPrefix(trimmed, "~~~") { + marker = "~~~" + } + + if !inFence { + if marker == "" { + out = append(out, line) + continue + } + inFence = true + fenceMarker = marker + block = append(block, line) + continue + } + + block = append(block, line) + if marker == fenceMarker { + flushBlock() + inFence = false + fenceMarker = "" + } + } + + if inFence { + flushBlock() + } + + return strings.Join(out, "\n"), codeBlocks } func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string { @@ -818,6 +858,37 @@ func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string { return restored } +func protectMarkdownInlineCode(text string) (string, []string) { + var inlineCodes []string + lines := strings.Split(text, "\n") + for i, line := range lines { + spans := scanInlineCodeSpans(line) + if len(spans) == 0 { + continue + } + var sb strings.Builder + pos := 0 + for _, span := range spans { + sb.WriteString(line[pos:span[0]]) + idx := len(inlineCodes) + inlineCodes = append(inlineCodes, line[span[0]:span[1]]) + sb.WriteString(fmt.Sprintf("%s%d___", markdownInlineCodePlaceholder, idx)) + pos = span[1] + } + sb.WriteString(line[pos:]) + lines[i] = sb.String() + } + return strings.Join(lines, "\n"), inlineCodes +} + +func restoreMarkdownInlineCode(text string, inlineCodes []string) string { + restored := text + for i, code := range inlineCodes { + restored = strings.Replace(restored, fmt.Sprintf("%s%d___", markdownInlineCodePlaceholder, i), code, 1) + } + return restored +} + func optimizeMarkdownStyle(text string) string { r, codeBlocks := protectMarkdownCodeBlocks(text) r = normalizeMarkdownEmphasisSpacing(r) @@ -858,20 +929,17 @@ func optimizeMarkdownStyle(text string) string { // "**bold**" so Feishu's md renderer can recognize emphasis instead of // leaking literal '*'. func normalizeMarkdownEmphasisSpacing(markdown string) string { - lines := strings.Split(markdown, "\n") - inFence := false + protected, codeBlocks := protectMarkdownCodeBlocks(markdown) + protected, inlineCodes := protectMarkdownInlineCode(protected) + + lines := strings.Split(protected, "\n") for i, line := range lines { - trimmed := strings.TrimLeft(line, " \t") - if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { - inFence = !inFence - continue - } - if inFence { - continue - } - lines[i] = normalizeMarkdownEmphasisSpacingLine(line) + lines[i] = normalizeEmphasisSpacingSegment(line) } - return strings.Join(lines, "\n") + + normalized := strings.Join(lines, "\n") + normalized = restoreMarkdownInlineCode(normalized, inlineCodes) + return restoreMarkdownCodeBlocks(normalized, codeBlocks) } // scanInlineCodeSpans returns byte ranges [start, end) for inline code spans @@ -910,22 +978,6 @@ func scanInlineCodeSpans(line string) [][2]int { return spans } -func normalizeMarkdownEmphasisSpacingLine(line string) string { - spans := scanInlineCodeSpans(line) - if len(spans) == 0 { - return normalizeEmphasisSpacingSegment(line) - } - var sb strings.Builder - pos := 0 - for _, loc := range spans { - sb.WriteString(normalizeEmphasisSpacingSegment(line[pos:loc[0]])) - sb.WriteString(line[loc[0]:loc[1]]) - pos = loc[1] - } - sb.WriteString(normalizeEmphasisSpacingSegment(line[pos:])) - return sb.String() -} - func normalizeEmphasisSpacingSegment(seg string) string { if !strings.Contains(seg, "*") { return seg diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 1b023d05a..5df47d070 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -545,6 +545,16 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) + t.Run("preserves multiple inline code spans and tilde fenced code", func(t *testing.T) { + input := "`** keep **` prose ** fix ** ``* keep *``\n~~~md\n* keep fenced *\n~~~" + got := wrapMarkdownAsPost(input) + node := decodePostParagraphForTest(t, got, 0) + text, _ := node["text"].(string) + if text != "`** keep **` prose **fix** ``* keep *``\n~~~md\n* keep fenced *\n~~~" { + t.Fatalf("wrapMarkdownAsPost() text = %#v", node["text"]) + } + }) + t.Run("preserves embedded non emphatic spaced asterisks", func(t *testing.T) { got := wrapMarkdownAsPost("hello** world **there") node := decodePostParagraphForTest(t, got, 0) From dbf26133dce9aad0d87ae2709f338007fab91167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E9=87=91=E6=9D=B0?= Date: Sun, 26 Apr 2026 21:29:05 +0800 Subject: [PATCH 7/7] refactor(im): minimize emphasis normalization changes Change-Id: I5334e1c690d822a5e5735bfd1f9e8d14f65ddff3 --- shortcuts/im/helpers.go | 51 +++++------------------------------- shortcuts/im/helpers_test.go | 6 ++--- 2 files changed, 9 insertions(+), 48 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index ba0d84306..a4c2830d2 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -785,6 +785,7 @@ var ( reTableAfter = regexp.MustCompile(`(?m)((?:^\|.+\|[^\S\n]*\n?)+)`) reExcessNL = regexp.MustCompile(`\n{3,}`) reInvalidImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) + reCodeBlock = regexp.MustCompile("```[\\s\\S]*?```") reBlankLineSeparator = regexp.MustCompile(`\n(?:[ \t]*\n)+`) ) @@ -801,53 +802,13 @@ type markdownPart struct { } func protectMarkdownCodeBlocks(text string) (string, []string) { - lines := strings.Split(text, "\n") - out := make([]string, 0, len(lines)) var codeBlocks []string - var block []string - inFence := false - fenceMarker := "" - - flushBlock := func() { + protected := reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { idx := len(codeBlocks) - codeBlocks = append(codeBlocks, strings.Join(block, "\n")) - out = append(out, fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx)) - block = nil - } - - for _, line := range lines { - trimmed := strings.TrimLeft(line, " \t") - marker := "" - if strings.HasPrefix(trimmed, "```") { - marker = "```" - } else if strings.HasPrefix(trimmed, "~~~") { - marker = "~~~" - } - - if !inFence { - if marker == "" { - out = append(out, line) - continue - } - inFence = true - fenceMarker = marker - block = append(block, line) - continue - } - - block = append(block, line) - if marker == fenceMarker { - flushBlock() - inFence = false - fenceMarker = "" - } - } - - if inFence { - flushBlock() - } - - return strings.Join(out, "\n"), codeBlocks + codeBlocks = append(codeBlocks, m) + return fmt.Sprintf("%s%d___", markdownCodeBlockPlaceholder, idx) + }) + return protected, codeBlocks } func restoreMarkdownCodeBlocks(text string, codeBlocks []string) string { diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index 5df47d070..0bdae7bd3 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -545,12 +545,12 @@ func TestWrapMarkdownAsPost(t *testing.T) { } }) - t.Run("preserves multiple inline code spans and tilde fenced code", func(t *testing.T) { - input := "`** keep **` prose ** fix ** ``* keep *``\n~~~md\n* keep fenced *\n~~~" + t.Run("preserves multiple inline code spans", func(t *testing.T) { + input := "`** keep **` prose ** fix ** ``* keep *`` and * okay *" got := wrapMarkdownAsPost(input) node := decodePostParagraphForTest(t, got, 0) text, _ := node["text"].(string) - if text != "`** keep **` prose **fix** ``* keep *``\n~~~md\n* keep fenced *\n~~~" { + if text != "`** keep **` prose **fix** ``* keep *`` and *okay*" { t.Fatalf("wrapMarkdownAsPost() text = %#v", node["text"]) } })