@@ -6,6 +6,8 @@ package doc
66import (
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.
3440func 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.
120130var (
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 .
187195func 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+
217303var setextRe = regexp .MustCompile (`(?m)^([^\n]+)\n(-{3,}\s*$)` )
218304
219305func fixSetextAmbiguity (md string ) string {
@@ -291,6 +377,44 @@ var contentContainers = [][2]string{
291377// indented (nested) items.
292378var 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,
0 commit comments