Skip to content

Commit 0fea755

Browse files
authored
fix(doc): preserve round-trip formatting in fetch output (larksuite#469)
* fix(doc): preserve round-trip formatting in fetch output - trim leading spaces inside bold and italic emphasis exported by docs +fetch - normalize nested list indentation to avoid flattening and literal text on re-import - add regression tests for emphasis spacing and nested list indentation * fix(doc): avoid false positives in markdown spacing fixes - keep literal * x * and ** x ** text unchanged - only normalize indented nested list markers when a parent list item exists - add regression coverage for both CodeRabbit findings * fix(doc): 修正嵌套列表缩进的空行误判 - 遇到空行时停止向上查找父级列表项,避免把 loose list sibling 误改成嵌套列表 - 避免把列表项中的四空格缩进代码块误改成 tab 缩进列表项 - 补充两个回归测试,并更新 fixBoldSpacing 注释使其与当前实现一致 * fix(doc): 修复 Markdown emphasis 空格回写 - 将 fixBoldSpacingLine 改为按星号 run 扫描,修复 ** hello **、* hello * 和同一行多个 italic span 的空格清理 - 保留 inline code、heading 和 *** hello** 这类近邻字面量,避免误改 emphasis nesting
1 parent ebc6f06 commit 0fea755

2 files changed

Lines changed: 257 additions & 26 deletions

File tree

shortcuts/doc/markdown_fix.go

Lines changed: 150 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ package doc
66
import (
77
"regexp"
88
"strings"
9+
"unicode"
10+
"unicode/utf8"
911
)
1012

1113
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
@@ -15,24 +17,29 @@ import (
1517
// and strips redundant ** from ATX headings. Applied only outside fenced
1618
// code blocks, and skips inline code spans.
1719
//
18-
// 2. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
20+
// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list
21+
// markers to tab-indented markers. This avoids nested ordered list items
22+
// being flattened or interpreted as plain text/code on re-import.
23+
//
24+
// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
1925
// follows a non-empty line, preventing it from being parsed as a Setext H2.
2026
// Applied only outside fenced code blocks.
2127
//
22-
// 3. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
28+
// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
2329
// consecutive blockquote content lines so create-doc preserves line breaks.
2430
// Applied only outside fenced code blocks.
2531
//
26-
// 4. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
32+
// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
2733
// lines at the top level and inside content containers (callout,
2834
// quote-container, lark-td). Code fences are left untouched, and
2935
// consecutive list items / continuations are not separated.
3036
//
31-
// 5. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
37+
// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
3238
// actual Unicode emoji characters that create-doc understands. Applied only
3339
// outside fenced code blocks.
3440
func fixExportedMarkdown(md string) string {
3541
md = applyOutsideCodeFences(md, fixBoldSpacing)
42+
md = applyOutsideCodeFences(md, normalizeNestedListIndentation)
3643
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
3744
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
3845
md = fixTopLevelSoftbreaks(md)
@@ -106,20 +113,21 @@ func fixBlockquoteHardBreaks(md string) string {
106113
return strings.Join(out, "\n")
107114
}
108115

109-
// fixBoldSpacing fixes two issues with bold markers exported by Lark:
116+
// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving
117+
// inline code spans:
118+
//
119+
// 1. Removes leading whitespace after opening ** and * delimiters:
120+
// "** text**" → "**text**", "* text*" → "*text*"
110121
//
111-
// 1. Trailing whitespace before closing **: "**text **" → "**text**"
112-
// CommonMark requires no space before a closing delimiter; otherwise the
113-
// ** is rendered as literal text.
122+
// 2. Removes trailing whitespace before closing ** and * delimiters:
123+
// "**text **" → "**text**", "*text *" → "*text*"
114124
//
115-
// 2. Redundant bold in ATX headings: "# **text**" → "# text"
116-
// Headings are already bold, so the inner ** is visually redundant and
117-
// some renderers display the markers literally.
125+
// 3. Removes redundant bold around an entire ATX heading:
126+
// "# **text**" → "# text"
118127
//
119-
// Both fixes skip inline code spans to avoid modifying literal code content.
128+
// The bold and italic spacing fixes only run on non-code segments so literal
129+
// code content is left unchanged.
120130
var (
121-
boldTrailingSpaceRe = regexp.MustCompile(`(\*\*\S[^*]*?)\s+(\*\*)`)
122-
italicTrailingSpaceRe = regexp.MustCompile(`(\*\S[^*]*?)\s+(\*)`)
123131
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
124132
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
125133
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
@@ -182,38 +190,116 @@ func scanInlineCodeSpans(line string) [][2]int {
182190
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
183191
// skipping content inside inline code spans to avoid corrupting literal code.
184192
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
185-
// handles them separately and boldTrailingSpaceRe can misfire on headings with
186-
// multiple disjoint bold spans (e.g. "# **foo** and **bar**").
193+
// handles them separately, keeping heading-only normalization isolated from the
194+
// inline emphasis spacing scanner below.
187195
func fixBoldSpacingLine(line string) string {
188196
if atxHeadingRe.MatchString(line) {
189197
return line
190198
}
191199
spans := scanInlineCodeSpans(line)
192200
if len(spans) == 0 {
193-
line = boldTrailingSpaceRe.ReplaceAllString(line, "$1$2")
194-
line = italicTrailingSpaceRe.ReplaceAllString(line, "$1$2")
195-
return line
201+
return fixEmphasisSpacingSegment(line)
196202
}
197203
var sb strings.Builder
198204
pos := 0
199205
for _, loc := range spans {
200206
// Process the non-code segment before this inline code span.
201207
seg := line[pos:loc[0]]
202-
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
203-
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
204-
sb.WriteString(seg)
208+
sb.WriteString(fixEmphasisSpacingSegment(seg))
205209
// Preserve inline code span as-is.
206210
sb.WriteString(line[loc[0]:loc[1]])
207211
pos = loc[1]
208212
}
209213
// Remaining non-code segment after the last code span.
210-
seg := line[pos:]
211-
seg = boldTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
212-
seg = italicTrailingSpaceRe.ReplaceAllString(seg, "$1$2")
213-
sb.WriteString(seg)
214+
sb.WriteString(fixEmphasisSpacingSegment(line[pos:]))
214215
return sb.String()
215216
}
216217

218+
// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple
219+
// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and
220+
// any candidate whose payload contains another asterisk so nested emphasis-like
221+
// text remains untouched. When both inner sides contain whitespace, single-rune
222+
// payloads are preserved as literal text (for example "* x *" and "** x **").
223+
func fixEmphasisSpacingSegment(seg string) string {
224+
if !strings.Contains(seg, "*") {
225+
return seg
226+
}
227+
228+
var sb strings.Builder
229+
pos := 0
230+
for pos < len(seg) {
231+
openStart, openEnd, ok := nextAsteriskRun(seg, pos)
232+
if !ok {
233+
sb.WriteString(seg[pos:])
234+
break
235+
}
236+
237+
sb.WriteString(seg[pos:openStart])
238+
239+
markerLen := openEnd - openStart
240+
if markerLen != 1 && markerLen != 2 {
241+
sb.WriteString(seg[openStart:openEnd])
242+
pos = openEnd
243+
continue
244+
}
245+
246+
closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd)
247+
if !ok || closeEnd-closeStart != markerLen {
248+
sb.WriteString(seg[openStart:openEnd])
249+
pos = openEnd
250+
continue
251+
}
252+
253+
payload := seg[openEnd:closeStart]
254+
normalized, shouldNormalize := normalizeEmphasisPayload(payload)
255+
if !shouldNormalize {
256+
sb.WriteString(seg[openStart:closeEnd])
257+
pos = closeEnd
258+
continue
259+
}
260+
261+
marker := seg[openStart:openEnd]
262+
sb.WriteString(marker)
263+
sb.WriteString(normalized)
264+
sb.WriteString(marker)
265+
pos = closeEnd
266+
}
267+
return sb.String()
268+
}
269+
270+
func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) {
271+
for i := start; i < len(s); i++ {
272+
if s[i] != '*' {
273+
continue
274+
}
275+
j := i
276+
for j < len(s) && s[j] == '*' {
277+
j++
278+
}
279+
return i, j, true
280+
}
281+
return 0, 0, false
282+
}
283+
284+
func normalizeEmphasisPayload(payload string) (string, bool) {
285+
trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace)
286+
trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace)
287+
if trimmed == "" {
288+
return payload, false
289+
}
290+
291+
hasLeadingSpace := len(trimmedLeft) != len(payload)
292+
hasTrailingSpace := len(trimmed) != len(trimmedLeft)
293+
if !hasLeadingSpace && !hasTrailingSpace {
294+
return payload, true
295+
}
296+
297+
if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 {
298+
return payload, false
299+
}
300+
return trimmed, true
301+
}
302+
217303
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
218304

219305
func fixSetextAmbiguity(md string) string {
@@ -291,6 +377,44 @@ var contentContainers = [][2]string{
291377
// indented (nested) items.
292378
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
293379

380+
// nestedListIndentRe matches nested list item markers indented with pairs of
381+
// spaces. We rewrite those space pairs to tabs because some downstream
382+
// round-trip paths treat multi-space indented ordered items as flat items or
383+
// literal text, while tab indentation remains nested and avoids 4-space code
384+
// block ambiguity.
385+
var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `)
386+
387+
func normalizeNestedListIndentation(md string) string {
388+
lines := strings.Split(md, "\n")
389+
for i, line := range lines {
390+
matches := nestedListIndentRe.FindStringSubmatch(line)
391+
if len(matches) != 3 {
392+
continue
393+
}
394+
if !hasPreviousNonBlankListItem(lines, i) {
395+
continue
396+
}
397+
indent := matches[1]
398+
if len(indent)%2 != 0 {
399+
continue
400+
}
401+
tabs := strings.Repeat("\t", len(indent)/2)
402+
lines[i] = tabs + line[len(indent):]
403+
}
404+
return strings.Join(lines, "\n")
405+
}
406+
407+
func hasPreviousNonBlankListItem(lines []string, index int) bool {
408+
for i := index - 1; i >= 0; i-- {
409+
trimmed := strings.TrimSpace(lines[i])
410+
if trimmed == "" {
411+
return false
412+
}
413+
return listItemRe.MatchString(lines[i])
414+
}
415+
return false
416+
}
417+
294418
// isListItemOrContinuation returns true for lines that are part of a list:
295419
// either a list item marker line or an indented continuation of a list item.
296420
// This is used to prevent blank lines being inserted between tight list lines,

shortcuts/doc/markdown_fix_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,56 @@ func TestFixBoldSpacing(t *testing.T) {
1414
input string
1515
want string
1616
}{
17+
{
18+
name: "leading space after opening bold",
19+
input: "** hello**",
20+
want: "**hello**",
21+
},
22+
{
23+
name: "leading space after opening italic",
24+
input: "* hello*",
25+
want: "*hello*",
26+
},
27+
{
28+
name: "leading and trailing spaces inside bold are collapsed",
29+
input: "** hello **",
30+
want: "**hello**",
31+
},
32+
{
33+
name: "leading and trailing spaces inside italic are collapsed",
34+
input: "* hello *",
35+
want: "*hello*",
36+
},
37+
{
38+
name: "multiple spaced italic spans on one line are each collapsed",
39+
input: "* a* * b*",
40+
want: "*a* *b*",
41+
},
42+
{
43+
name: "ambiguous italic span stays literal",
44+
input: "2 * x * y",
45+
want: "2 * x * y",
46+
},
47+
{
48+
name: "ambiguous bold span stays literal",
49+
input: "2 ** x ** y",
50+
want: "2 ** x ** y",
51+
},
52+
{
53+
name: "single-rune italic with spaces on both sides stays literal",
54+
input: "* x *",
55+
want: "* x *",
56+
},
57+
{
58+
name: "single-rune bold with spaces on both sides stays literal",
59+
input: "** x **",
60+
want: "** x **",
61+
},
62+
{
63+
name: "triple-asterisk near miss stays literal",
64+
input: "*** hello**",
65+
want: "*** hello**",
66+
},
1767
{
1868
name: "trailing space before closing bold",
1969
input: "**hello **",
@@ -54,6 +104,16 @@ func TestFixBoldSpacing(t *testing.T) {
54104
input: "**foo ** and `**bar **`",
55105
want: "**foo** and `**bar **`",
56106
},
107+
{
108+
name: "inline code with spaced italic stays literal while outside span is fixed",
109+
input: "`* hello *` and * hello *",
110+
want: "`* hello *` and *hello*",
111+
},
112+
{
113+
name: "opening space inside text tag fixed",
114+
input: `<text color="red">** Helpful - 有用性:**</text>`,
115+
want: `<text color="red">**Helpful - 有用性:**</text>`,
116+
},
57117
{
58118
name: "double-backtick inline code not modified",
59119
input: "``**hello **`` and **world **",
@@ -222,6 +282,53 @@ func TestFixTopLevelSoftbreaks(t *testing.T) {
222282
}
223283
}
224284

285+
func TestNormalizeNestedListIndentation(t *testing.T) {
286+
tests := []struct {
287+
name string
288+
input string
289+
want string
290+
}{
291+
{
292+
name: "nested ordered list uses tabs instead of space pairs",
293+
input: "1. parent\n 1. child\n 1. grandchild",
294+
want: "1. parent\n\t1. child\n\t\t1. grandchild",
295+
},
296+
{
297+
name: "nested mixed list markers use tabs instead of space pairs",
298+
input: "- parent\n - child\n 1. grandchild",
299+
want: "- parent\n\t- child\n\t\t1. grandchild",
300+
},
301+
{
302+
name: "top-level list unchanged",
303+
input: "1. parent\n2. sibling",
304+
want: "1. parent\n2. sibling",
305+
},
306+
{
307+
name: "indented top-level marker without parent list stays unchanged",
308+
input: "paragraph\n\n 1. item",
309+
want: "paragraph\n\n 1. item",
310+
},
311+
{
312+
name: "blank-line-separated loose-list sibling stays unchanged",
313+
input: "1. a\n\n 1. b",
314+
want: "1. a\n\n 1. b",
315+
},
316+
{
317+
name: "indented code block inside list item stays unchanged",
318+
input: "- parent\n\n 1. code",
319+
want: "- parent\n\n 1. code",
320+
},
321+
}
322+
for _, tt := range tests {
323+
t.Run(tt.name, func(t *testing.T) {
324+
got := normalizeNestedListIndentation(tt.input)
325+
if got != tt.want {
326+
t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want)
327+
}
328+
})
329+
}
330+
}
331+
225332
func TestFixExportedMarkdown(t *testing.T) {
226333
// End-to-end: all fixes applied together
227334
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"

0 commit comments

Comments
 (0)