Skip to content

fix(docs): trim trailing code block blanks#561

Open
OwenYWT wants to merge 1 commit intolarksuite:mainfrom
OwenYWT:fix/docs-code-block-trailing-line
Open

fix(docs): trim trailing code block blanks#561
OwenYWT wants to merge 1 commit intolarksuite:mainfrom
OwenYWT:fix/docs-code-block-trailing-line

Conversation

@OwenYWT
Copy link
Copy Markdown
Contributor

@OwenYWT OwenYWT commented Apr 20, 2026

Summary

  • trim blank lines immediately before fenced code block closing markers in Markdown crossing the docs CLI boundary
  • apply the normalization to docs create/update/fetch and markdown drive export
  • add regression coverage for accumulated trailing blank lines while preserving internal code blank lines

Fixes #53.

Test plan

  • go test ./shortcuts/common ./shortcuts/doc ./shortcuts/drive
  • git diff --cached --check

Summary by CodeRabbit

  • Bug Fixes
    • Fixed trailing blank lines appearing in Markdown code blocks when creating, updating, fetching, and exporting documents, improving formatting consistency across all document operations.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

A new Markdown utility function TrimMarkdownCodeBlockTrailingBlanks is introduced to remove trailing blank lines from fenced code blocks, then applied consistently across document creation, fetching, updating, and drive export operations.

Changes

Cohort / File(s) Summary
Core Markdown Utility
shortcuts/common/markdown.go, shortcuts/common/markdown_test.go
Introduces fence detection logic to identify and trim trailing blank lines within fenced code blocks (backtick or tilde fences), with comprehensive test coverage for various scenarios.
Document Operations
shortcuts/doc/docs_create.go, shortcuts/doc/docs_fetch.go, shortcuts/doc/docs_update.go
Applies the trim function to normalize Markdown content at creation, fetch, and update stages; docs_update.go also updates conditional checks to reference the trimmed value.
Drive Export
shortcuts/drive/drive_export.go
Trims trailing blanks from fetched Markdown before writing to file and updates size metric accordingly for markdown exports.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • fangshuyu-768

Poem

🐰✨ A rabbit hops through fences fine,
Trimming blanks from every line,
Code blocks now stay crisp and neat,
No more gaps, the fix complete! 📝

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(docs): trim trailing code block blanks' accurately describes the main change—trimming trailing blank lines from Markdown code blocks in the docs CLI.
Description check ✅ Passed The description follows the template with summary, changes, and test plan sections. All major modifications are outlined, though it deviates slightly by using a compact summary format instead of detailed bullet points.
Linked Issues check ✅ Passed The PR directly addresses issue #53 by implementing code to trim trailing blank lines before code block closing markers across docs create/update/fetch and drive export operations.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing the trailing blank line issue in Markdown code blocks; no unrelated modifications were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added domain/ccm PR touches the ccm domain size/L Large or sensitive change across domains or core paths labels Apr 20, 2026
@OwenYWT OwenYWT force-pushed the fix/docs-code-block-trailing-line branch from add93e0 to 5387ee8 Compare April 20, 2026 03:43
@OwenYWT OwenYWT marked this pull request as ready for review April 20, 2026 04:34
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (5)
shortcuts/doc/docs_fetch.go (1)

67-73: Collapse the two result["markdown"] type-assert blocks.

Lines 67–69 and 71–73 do the same .(string) assertion back-to-back. They can be merged into a single block that applies both transforms, which also avoids an unnecessary re-fetch from the map:

♻️ Proposed refactor
-		if md, ok := result["markdown"].(string); ok {
-			result["markdown"] = common.TrimMarkdownCodeBlockTrailingBlanks(md)
-		}
-
-		if md, ok := result["markdown"].(string); ok {
-			result["markdown"] = fixExportedMarkdown(md)
-		}
+		if md, ok := result["markdown"].(string); ok {
+			result["markdown"] = fixExportedMarkdown(common.TrimMarkdownCodeBlockTrailingBlanks(md))
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_fetch.go` around lines 67 - 73, Combine the two repeated
type-assert blocks that check result["markdown"] into a single type assertion:
perform one check like `md, ok := result["markdown"].(string)` and if ok set
`result["markdown"]` after applying both transformations in sequence (first call
common.TrimMarkdownCodeBlockTrailingBlanks(md) and then pass the result to
fixExportedMarkdown), thus avoiding the double map lookup and applying both
transforms before writing back to result.
shortcuts/doc/docs_create.go (1)

55-66: Pass the trimmed markdown to normalizeDocsUpdateResult for consistency with docs_update.go.

buildDocsCreateArgs trims the markdown before sending it to the MCP tool, but line 63 then calls normalizeDocsUpdateResult(result, runtime.Str("markdown")) with the untrimmed raw value. In docs_update.go (line 115) the trimmed value is passed. The downstream check is isWhiteboardCreateMarkdown which scans for ```mermaid / ```plantuml / <whiteboard> tokens — trimming trailing blanks inside code blocks won't change those detections, so this is a consistency nit rather than a correctness bug, but worth aligning:

♻️ Proposed refactor
-	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
-		args := buildDocsCreateArgs(runtime)
-		result, err := common.CallMCPTool(runtime, "create-doc", args)
+	Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+		args := buildDocsCreateArgs(runtime)
+		result, err := common.CallMCPTool(runtime, "create-doc", args)
 		if err != nil {
 			return err
 		}
 		augmentDocsCreateResult(runtime, result)
 
-		normalizeDocsUpdateResult(result, runtime.Str("markdown"))
+		normalizeDocsUpdateResult(result, common.GetString(args, "markdown"))
 		runtime.Out(result, nil)
 		return nil
 	},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_create.go` around lines 55 - 66, The call to
normalizeDocsUpdateResult uses the raw runtime.Str("markdown") but
buildDocsCreateArgs trims the markdown first; update the Execute block so
normalizeDocsUpdateResult receives the same trimmed markdown used by
buildDocsCreateArgs (e.g., obtain the trimmed markdown from the args returned by
buildDocsCreateArgs or apply the same trim to runtime.Str("markdown")) to match
docs_update.go behavior and keep isWhiteboardCreateMarkdown detection
consistent; target functions/values: buildDocsCreateArgs,
normalizeDocsUpdateResult, and runtime.Str("markdown").
shortcuts/doc/docs_update.go (1)

68-90: DryRun and Execute paths duplicate the trim call — consider factoring.

Both branches independently call common.TrimMarkdownCodeBlockTrailingBlanks(runtime.Str("markdown")) with slightly different conditional shapes (line 73–75 gates on the raw string being non-empty; line 97 gates on the trimmed value). Trimming never turns a non-empty string into empty (it only drops blank lines inside fences), so both conditions are equivalent in practice — but extracting a small buildDocsUpdateArgs(runtime) (args map[string]interface{}, markdown string) helper (mirroring buildDocsCreateArgs in docs_create.go) would remove the duplication and guarantee DryRun and Execute stay aligned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/doc/docs_update.go` around lines 68 - 90, The DryRun block and the
Execute path both duplicate trimming of the markdown string; create a helper
function buildDocsUpdateArgs(runtime *common.RuntimeContext) (args
map[string]interface{}, markdown string) (mirroring buildDocsCreateArgs in
docs_create.go) that constructs the args map, calls
common.TrimMarkdownCodeBlockTrailingBlanks(runtime.Str("markdown")) once, and
returns the args plus the trimmed markdown; then replace the inline arg-building
in DryRun (the anonymous DryRun func) and the Execute handler to call
buildDocsUpdateArgs and use its returned args/markdown so both paths share the
same logic and eliminate the duplicated Trim call referenced in the DryRun
closure and the Execute code paths.
shortcuts/common/markdown_test.go (1)

8-52: Consider adding a few more edge-case cases.

Good coverage for the common scenarios. A couple of inexpensive additions would guard against regressions:

  • Empty input ("") — exercises the early-return path.
  • Unterminated fence (no closing ``` before EOF) — verifies buffered blanks are flushed rather than dropped.
  • Nested/longer fences, e.g. a ```` opener containing a ``` line inside — verifies the closer match uses the opener's size.
  • Input without a trailing newline on the last line.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/common/markdown_test.go` around lines 8 - 52, Add tests to
TestTrimMarkdownCodeBlockTrailingBlanks that cover the empty string case (""),
an unterminated fence (e.g., an opening ``` with EOF and trailing blank lines to
ensure buffered blanks are preserved), a longer/multi-backtick opener (e.g., a
```` opener that contains a ``` line to ensure the closer matches opener
length), and a case where the input does not end with a trailing newline;
reference the existing test harness and the TrimMarkdownCodeBlockTrailingBlanks
function when adding these new table-driven entries so they run under t.Run
alongside the current cases.
shortcuts/common/markdown.go (1)

13-42: Fence detection edge cases to be aware of.

A couple of non-blocking gotchas in parseMarkdownFence/isMarkdownFenceClose:

  1. strings.TrimSpace strips leading whitespace, so an indented-code-block line that happens to start with ` ```` (4+ spaces then backticks) will be misclassified as a fence opener. In practice, Lark's markdown round-trip emits fenced code blocks, so this is unlikely to trigger — but worth noting.
  2. For backtick fences, CommonMark forbids backticks in the info string (e.g. ```foo`bar). You don't validate this, which means such a line could be treated as a fence opener and the closer would then eat content. Again, unlikely in real Lark docs.
  3. isMarkdownFenceClose correctly requires no info string after the closing run; good.

No action required if the scope is intentionally limited to the shapes Lark emits — matches the existing fence-detection posture elsewhere in shortcuts/doc/markdown_fix.go.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shortcuts/common/markdown.go` around lines 13 - 42, parseMarkdownFence and
isMarkdownFenceClose misclassify indented code blocks and don't validate
backticks in the info string: update parseMarkdownFence to first detect and
reject lines with 4 or more leading spaces (preserve leading whitespace instead
of using strings.TrimSpace) so indented code blocks aren't treated as fences,
then compute the fence run on the untrimmed suffix; additionally, when the
detected fence char is '`', validate that the remainder of the line after the
fence run (the info string) does not contain any backticks and reject as a fence
if it does; keep isMarkdownFenceClose behavior but ensure it also uses the same
leading-whitespace rule (reject closing fences that are indented >=4 spaces) and
still requires no trailing non-space content after the closing run. Reference
functions: parseMarkdownFence and isMarkdownFenceClose.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@shortcuts/common/markdown_test.go`:
- Around line 8-52: Add tests to TestTrimMarkdownCodeBlockTrailingBlanks that
cover the empty string case (""), an unterminated fence (e.g., an opening ```
with EOF and trailing blank lines to ensure buffered blanks are preserved), a
longer/multi-backtick opener (e.g., a ```` opener that contains a ``` line to
ensure the closer matches opener length), and a case where the input does not
end with a trailing newline; reference the existing test harness and the
TrimMarkdownCodeBlockTrailingBlanks function when adding these new table-driven
entries so they run under t.Run alongside the current cases.

In `@shortcuts/common/markdown.go`:
- Around line 13-42: parseMarkdownFence and isMarkdownFenceClose misclassify
indented code blocks and don't validate backticks in the info string: update
parseMarkdownFence to first detect and reject lines with 4 or more leading
spaces (preserve leading whitespace instead of using strings.TrimSpace) so
indented code blocks aren't treated as fences, then compute the fence run on the
untrimmed suffix; additionally, when the detected fence char is '`', validate
that the remainder of the line after the fence run (the info string) does not
contain any backticks and reject as a fence if it does; keep
isMarkdownFenceClose behavior but ensure it also uses the same
leading-whitespace rule (reject closing fences that are indented >=4 spaces) and
still requires no trailing non-space content after the closing run. Reference
functions: parseMarkdownFence and isMarkdownFenceClose.

In `@shortcuts/doc/docs_create.go`:
- Around line 55-66: The call to normalizeDocsUpdateResult uses the raw
runtime.Str("markdown") but buildDocsCreateArgs trims the markdown first; update
the Execute block so normalizeDocsUpdateResult receives the same trimmed
markdown used by buildDocsCreateArgs (e.g., obtain the trimmed markdown from the
args returned by buildDocsCreateArgs or apply the same trim to
runtime.Str("markdown")) to match docs_update.go behavior and keep
isWhiteboardCreateMarkdown detection consistent; target functions/values:
buildDocsCreateArgs, normalizeDocsUpdateResult, and runtime.Str("markdown").

In `@shortcuts/doc/docs_fetch.go`:
- Around line 67-73: Combine the two repeated type-assert blocks that check
result["markdown"] into a single type assertion: perform one check like `md, ok
:= result["markdown"].(string)` and if ok set `result["markdown"]` after
applying both transformations in sequence (first call
common.TrimMarkdownCodeBlockTrailingBlanks(md) and then pass the result to
fixExportedMarkdown), thus avoiding the double map lookup and applying both
transforms before writing back to result.

In `@shortcuts/doc/docs_update.go`:
- Around line 68-90: The DryRun block and the Execute path both duplicate
trimming of the markdown string; create a helper function
buildDocsUpdateArgs(runtime *common.RuntimeContext) (args
map[string]interface{}, markdown string) (mirroring buildDocsCreateArgs in
docs_create.go) that constructs the args map, calls
common.TrimMarkdownCodeBlockTrailingBlanks(runtime.Str("markdown")) once, and
returns the args plus the trimmed markdown; then replace the inline arg-building
in DryRun (the anonymous DryRun func) and the Execute handler to call
buildDocsUpdateArgs and use its returned args/markdown so both paths share the
same logic and eliminate the duplicated Trim call referenced in the DryRun
closure and the Execute code paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4bca7f9a-47d4-4980-a2da-fc0cc57735af

📥 Commits

Reviewing files that changed from the base of the PR and between 1262aac and 5387ee8.

📒 Files selected for processing (6)
  • shortcuts/common/markdown.go
  • shortcuts/common/markdown_test.go
  • shortcuts/doc/docs_create.go
  • shortcuts/doc/docs_fetch.go
  • shortcuts/doc/docs_update.go
  • shortcuts/drive/drive_export.go

Copy link
Copy Markdown
Collaborator

@fangshuyu-768 fangshuyu-768 left a comment

Choose a reason for hiding this comment

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

A few notes on top of what CodeRabbit already flagged (the docs_fetch.go double type-assert and the normalizeDocsUpdateResult consistency in docs_create.go). Biggest concern is scope vs. issue #53 — the rest are minor correctness / test-gap / DRY notes.

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?


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) == ""
}

}

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.

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.

}
})
}
}
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain/ccm PR touches the ccm domain size/L Large or sensitive change across domains or core paths

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs +create: Code blocks get a spurious trailing empty line 飞书文档中的代码块总是跟一个空白行

2 participants