Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions shortcuts/common/markdown.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TrimSpace over-tolerates indentation — 4-space-indented "fences" are actually indented code block content.

CommonMark allows 0–3 spaces of indentation before a fence marker; 4+ spaces means the line is an indented code block inside the surrounding context and the backticks are literal. Because parseMarkdownFence does strings.TrimSpace(line), a line like " ```" (four leading spaces) is treated as an opening fence, and subsequent lines are then ignored by TrimMarkdownCodeBlockTrailingBlanks until it sees a matching close.

In practice this is mostly academic for markdown coming out of Lark (server output isn't deeply indented), but it can trigger false positives if a user's source passed through docs +create/docs +update nests a literal fence example inside an indented block. Suggest matching CommonMark: count leading spaces, bail out if ≥ 4.

Same applies to isMarkdownFenceClose at L33.

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")) == ""
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strings.TrimRight(line, "\r\n") is redundant here.

strings.TrimSpace already strips \r, \n, \t, and space, so the chained TrimRight(line, "\r\n") has no effect on the result. Can simplify to:

func isMarkdownBlankLine(line string) bool {
    return strings.TrimSpace(line) == ""
}


// 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, "")
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two parallel fence parsers now live in the repo — worth a DRY note for a follow-up.

shortcuts/doc/markdown_fix.go has its own applyOutsideCodeFences that does fence tracking with a simpler, less-robust rule (it only recognises triple backticks, not ~~~, not variable fence length). This new shortcuts/common/markdown.go has more correct fence handling (either char, >= 3 length, matching close length).

Not a blocker for this PR, but once this lands it'd be worth switching applyOutsideCodeFences to drive off parseMarkdownFence / isMarkdownFenceClose so the two implementations can't drift (e.g. the old one doesn't currently handle ~~~ fences at all — see fixExportedMarkdown pipeline). Feel free to file a follow-up and leave this PR as-is.

52 changes: 52 additions & 0 deletions shortcuts/common/markdown_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few test-coverage gaps worth plugging while the test file is fresh:

  • CRLF line endings (Windows-authored markdown — the issue reporter is on Windows 11).
  • Fences longer than 3 chars: `````go\n…\n\n````` (5-backtick open, 5-backtick close) must be preserved; a 3-backtick line in the middle is not a close.
  • Input that does not end with a trailing newline (SplitAfter retains the final token without \n; worth confirming round-trip is byte-exact).
  • Unclosed fenced block (fence opened, EOF reached before a close) — current code keeps pendingBlanks at the end; the assertion just protects against regression.
  • Tab-only "blank" lines inside code (\t\n): currently dropped as blank by TrimSpace; confirm that matches intent for code blocks where tab-only lines can have semantic meaning (e.g. Makefiles, whitespace-sensitive DSLs).
  • Tilde fence with info string: ~~~python\nx\n\n~~~ (already have one tilde case, but without info string).

None of these are likely to be broken in the current implementation, but the test file doubles as a spec for future changes.

2 changes: 1 addition & 1 deletion shortcuts/doc/docs_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this actually close issue #53?

Issue #53 reports that a freshly-created Feishu document, opened in the Feishu UI, renders an extra blank line at the end of every code block — the user explicitly confirms the markdown sent to the API was already clean. That suggests the blank line is introduced by the MCP create-doc tool or the downstream docx block conversion, not by the CLI-side markdown.

This PR normalises markdown on the CLI side of the boundary (trim on create input, trim on fetch/export output). That will:

  • ✅ Prevent accumulation across fetch → edit → update round-trips (good and worth having).
  • ✅ Make docs +fetch / drive +export output look tidy.
  • ❌ Not change what renders inside Feishu after docs +create — the docx block created by the server still contains the server-added trailing blank.

If the intent is to close #53 as reported, the trim-on-input is a no-op here (the user's source markdown already didn't have the blank). Could we either (a) verify the fix in Feishu UI after this PR and update the issue, or (b) reword the PR / issue link from "Fixes #53" to "Mitigates round-trip growth related to #53" so it's clear the rendering issue remains open?

}
if v := runtime.Str("title"); v != "" {
args["title"] = v
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/doc/docs_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions shortcuts/doc/docs_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -111,7 +112,7 @@ var DocsUpdate = common.Shortcut{
return err
}

normalizeDocsUpdateResult(result, runtime.Str("markdown"))
normalizeDocsUpdateResult(result, markdown)
runtime.Out(result, nil)
return nil
},
Expand Down
5 changes: 3 additions & 2 deletions shortcuts/drive/drive_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading