From 5387ee87c88f715aa4b9903bb7d8f8186975eff7 Mon Sep 17 00:00:00 2001 From: OwenYWT Date: Mon, 20 Apr 2026 11:31:19 +0800 Subject: [PATCH] fix(docs): trim trailing code block blanks --- shortcuts/common/markdown.go | 95 +++++++++++++++++++++++++++++++ shortcuts/common/markdown_test.go | 52 +++++++++++++++++ shortcuts/doc/docs_create.go | 2 +- shortcuts/doc/docs_fetch.go | 3 + shortcuts/doc/docs_update.go | 9 +-- shortcuts/drive/drive_export.go | 5 +- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 shortcuts/common/markdown.go create mode 100644 shortcuts/common/markdown_test.go diff --git a/shortcuts/common/markdown.go b/shortcuts/common/markdown.go new file mode 100644 index 000000000..3aeaaa0cf --- /dev/null +++ b/shortcuts/common/markdown.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "strings" + +type markdownFence struct { + char byte + size int +} + +func parseMarkdownFence(line string) (markdownFence, bool) { + trimmed := strings.TrimSpace(line) + if len(trimmed) < 3 { + return markdownFence{}, false + } + char := trimmed[0] + if char != '`' && char != '~' { + return markdownFence{}, false + } + size := 0 + for size < len(trimmed) && trimmed[size] == char { + size++ + } + if size < 3 { + return markdownFence{}, false + } + return markdownFence{char: char, size: size}, true +} + +func isMarkdownFenceClose(line string, fence markdownFence) bool { + trimmed := strings.TrimSpace(line) + if len(trimmed) < fence.size { + return false + } + size := 0 + for size < len(trimmed) && trimmed[size] == fence.char { + size++ + } + return size >= fence.size && strings.TrimSpace(trimmed[size:]) == "" +} + +func isMarkdownBlankLine(line string) bool { + return strings.TrimSpace(strings.TrimRight(line, "\r\n")) == "" +} + +// TrimMarkdownCodeBlockTrailingBlanks removes blank lines immediately before +// fenced code block closing markers. Lark's document Markdown round-trip can +// append one such blank line per fetch/update cycle, causing code blocks to grow. +func TrimMarkdownCodeBlockTrailingBlanks(markdown string) string { + if markdown == "" { + return markdown + } + + lines := strings.SplitAfter(markdown, "\n") + out := make([]string, 0, len(lines)) + pendingBlanks := make([]string, 0, 2) + var fence markdownFence + inCodeBlock := false + + for _, line := range lines { + if !inCodeBlock { + out = append(out, line) + if parsed, ok := parseMarkdownFence(line); ok { + fence = parsed + inCodeBlock = true + } + continue + } + + if isMarkdownFenceClose(line, fence) { + pendingBlanks = pendingBlanks[:0] + out = append(out, line) + inCodeBlock = false + continue + } + + if isMarkdownBlankLine(line) { + pendingBlanks = append(pendingBlanks, line) + continue + } + + if len(pendingBlanks) > 0 { + out = append(out, pendingBlanks...) + pendingBlanks = pendingBlanks[:0] + } + out = append(out, line) + } + + if len(pendingBlanks) > 0 { + out = append(out, pendingBlanks...) + } + return strings.Join(out, "") +} diff --git a/shortcuts/common/markdown_test.go b/shortcuts/common/markdown_test.go new file mode 100644 index 000000000..f16fd108e --- /dev/null +++ b/shortcuts/common/markdown_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "testing" + +func TestTrimMarkdownCodeBlockTrailingBlanks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "trims one trailing blank line before closing fence", + in: "before\n```bash\necho hello\n\n```\nafter\n", + want: "before\n```bash\necho hello\n```\nafter\n", + }, + { + name: "trims accumulated trailing blank lines", + in: "```go\nfmt.Println(1)\n\n\n```\n", + want: "```go\nfmt.Println(1)\n```\n", + }, + { + name: "keeps intentional blank lines inside code content", + in: "```\nline one\n\nline two\n\n```\n", + want: "```\nline one\n\nline two\n```\n", + }, + { + name: "leaves non-code blank lines alone", + in: "one\n\n```text\nvalue\n```\n\ntwo\n", + want: "one\n\n```text\nvalue\n```\n\ntwo\n", + }, + { + name: "supports tilde fences", + in: "~~~json\n{}\n\n~~~\n", + want: "~~~json\n{}\n~~~\n", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := TrimMarkdownCodeBlockTrailingBlanks(tt.in); got != tt.want { + t.Fatalf("TrimMarkdownCodeBlockTrailingBlanks() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go index 69ec15c85..7e58ef966 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": common.TrimMarkdownCodeBlockTrailingBlanks(runtime.Str("markdown")), } if v := runtime.Str("title"); v != "" { args["title"] = v diff --git a/shortcuts/doc/docs_fetch.go b/shortcuts/doc/docs_fetch.go index d1ad3af23..7e5d3d2f4 100644 --- a/shortcuts/doc/docs_fetch.go +++ b/shortcuts/doc/docs_fetch.go @@ -64,6 +64,9 @@ var DocsFetch = common.Shortcut{ if err != nil { return err } + if md, ok := result["markdown"].(string); ok { + result["markdown"] = common.TrimMarkdownCodeBlockTrailingBlanks(md) + } if md, ok := result["markdown"].(string); ok { result["markdown"] = fixExportedMarkdown(md) diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go index ea80550ed..d4b19ad8d 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"] = common.TrimMarkdownCodeBlockTrailingBlanks(v) } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v @@ -89,12 +89,13 @@ var DocsUpdate = common.Shortcut{ Set("mcp_tool", "update-doc").Set("args", args) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + markdown := common.TrimMarkdownCodeBlockTrailingBlanks(runtime.Str("markdown")) args := map[string]interface{}{ "doc_id": runtime.Str("doc"), "mode": runtime.Str("mode"), } - if v := runtime.Str("markdown"); v != "" { - args["markdown"] = v + if markdown != "" { + args["markdown"] = markdown } if v := runtime.Str("selection-with-ellipsis"); v != "" { args["selection_with_ellipsis"] = v @@ -111,7 +112,7 @@ var DocsUpdate = common.Shortcut{ return err } - normalizeDocsUpdateResult(result, runtime.Str("markdown")) + normalizeDocsUpdateResult(result, markdown) runtime.Out(result, nil) return nil }, diff --git a/shortcuts/drive/drive_export.go b/shortcuts/drive/drive_export.go index 271e07637..5bad9b2bc 100644 --- a/shortcuts/drive/drive_export.go +++ b/shortcuts/drive/drive_export.go @@ -114,7 +114,8 @@ var DriveExport = common.Shortcut{ title = spec.Token } fileName := ensureExportFileExtension(sanitizeExportFileName(title, spec.Token), spec.FileExtension) - savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite) + content := common.TrimMarkdownCodeBlockTrailingBlanks(common.GetString(data, "content")) + savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite) if err != nil { return err } @@ -125,7 +126,7 @@ var DriveExport = common.Shortcut{ "file_extension": spec.FileExtension, "file_name": filepath.Base(savedPath), "saved_path": savedPath, - "size_bytes": len([]byte(common.GetString(data, "content"))), + "size_bytes": len([]byte(content)), }, nil) return nil }