Skip to content

Commit 5614dd0

Browse files
authored
fix(docs): reset paragraph style in markdown replacements (#760)
1 parent e90afe7 commit 5614dd0

3 files changed

Lines changed: 149 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Docs: add persisted, revision-locked request batches for composing supported mutations locally and submitting them atomically, with explicit split and partial-recovery modes. (#755)
99
- CLI: remove the separate `gog agent` and `exit-codes` helpers; expose stable exit codes and effective automation safety state through `gog schema --json`, and summarize the contract in root help. (#677)
1010
- CLI: add Git-style `gog help <command>`, make explicit output flags override environment defaults, validate color and JSON-only transforms before command execution, report early usage errors on stderr, and reject contradictory schema plain output.
11+
- Docs: prevent multi-paragraph Markdown range replacements from inheriting the matched paragraph's heading or list style. (#756) — thanks @sebsnyk.
1112

1213
## 0.24.0 - 2026-06-11
1314

internal/cmd/docs_find_replace_test.go

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,81 @@ func TestDocsFindReplace_MarkdownResetsInheritedStylesBeforeLeadingBold(t *testi
495495
}
496496
}
497497

498+
func TestDocsFindReplace_MarkdownResetsInheritedParagraphStyle(t *testing.T) {
499+
origDocs := newDocsService
500+
t.Cleanup(func() { newDocsService = origDocs })
501+
502+
var got docs.BatchUpdateDocumentRequest
503+
body := docBodyWithText("Section\n")
504+
content := body["body"].(map[string]any)["content"].([]any)
505+
content[1].(map[string]any)["paragraph"].(map[string]any)["paragraphStyle"] = map[string]any{
506+
"namedStyleType": "HEADING_2",
507+
}
508+
509+
docSvc, cleanup := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
510+
w.Header().Set("Content-Type", "application/json")
511+
switch {
512+
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/v1/documents/"):
513+
_ = json.NewEncoder(w).Encode(body)
514+
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, ":batchUpdate"):
515+
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
516+
t.Fatalf("decode batchUpdate: %v", err)
517+
}
518+
_ = json.NewEncoder(w).Encode(map[string]any{"documentId": "doc1"})
519+
default:
520+
http.NotFound(w, r)
521+
}
522+
})
523+
defer cleanup()
524+
newDocsService = func(context.Context, string) (*docs.Service, error) { return docSvc, nil }
525+
526+
replacement := "## New heading\n\nNormal prose.\n\n- bullet one\n- bullet two\n"
527+
err := runKong(t, &DocsFindReplaceCmd{}, []string{
528+
"doc1", "Section", replacement, "--format", "markdown", "--first",
529+
}, newDocsCmdContext(t), &RootFlags{Account: "a@b.com"})
530+
if err != nil {
531+
t.Fatalf("docs find-replace --format markdown --first: %v", err)
532+
}
533+
534+
var reset *docs.UpdateParagraphStyleRequest
535+
var heading *docs.UpdateParagraphStyleRequest
536+
var bullets *docs.CreateParagraphBulletsRequest
537+
for _, req := range got.Requests {
538+
switch {
539+
case req.UpdateParagraphStyle != nil &&
540+
req.UpdateParagraphStyle.ParagraphStyle != nil &&
541+
req.UpdateParagraphStyle.ParagraphStyle.NamedStyleType == docsNamedStyleNormalText:
542+
reset = req.UpdateParagraphStyle
543+
case req.UpdateParagraphStyle != nil &&
544+
req.UpdateParagraphStyle.ParagraphStyle != nil &&
545+
req.UpdateParagraphStyle.ParagraphStyle.NamedStyleType == docsNamedStyleHeading2:
546+
heading = req.UpdateParagraphStyle
547+
case req.CreateParagraphBullets != nil:
548+
bullets = req.CreateParagraphBullets
549+
}
550+
}
551+
if reset == nil || reset.Fields != "namedStyleType,indentStart,indentFirstLine" {
552+
t.Fatalf("missing NORMAL_TEXT paragraph reset: %#v", got.Requests)
553+
}
554+
if reset.ParagraphStyle.IndentStart == nil || reset.ParagraphStyle.IndentStart.Magnitude != 0 ||
555+
reset.ParagraphStyle.IndentFirstLine == nil || reset.ParagraphStyle.IndentFirstLine.Magnitude != 0 {
556+
t.Fatalf("paragraph reset does not clear inherited list indentation: %#v", reset.ParagraphStyle)
557+
}
558+
if heading == nil {
559+
t.Fatalf("missing explicit HEADING_2 request: %#v", got.Requests)
560+
}
561+
if bullets == nil {
562+
t.Fatalf("missing native bullet request: %#v", got.Requests)
563+
}
564+
if reset.Range.StartIndex > heading.Range.StartIndex || reset.Range.EndIndex < bullets.Range.EndIndex {
565+
t.Fatalf("paragraph reset does not cover formatted replacement: reset=%#v heading=%#v bullets=%#v", reset.Range, heading.Range, bullets.Range)
566+
}
567+
inserted := got.Requests[1].InsertText
568+
if inserted == nil || reset.Range.EndIndex != inserted.Location.Index+utf16Len(inserted.Text)+1 {
569+
t.Fatalf("full-paragraph reset must include the preserved anchor terminator: reset=%#v insert=%#v", reset.Range, inserted)
570+
}
571+
}
572+
498573
func TestDocsFindReplace_MarkdownCodeBlockStartsFreshParagraphWhenInline(t *testing.T) {
499574
origDocs := newDocsService
500575
t.Cleanup(func() { newDocsService = origDocs })
@@ -535,18 +610,18 @@ func TestDocsFindReplace_MarkdownCodeBlockStartsFreshParagraphWhenInline(t *test
535610
t.Fatalf("expected 1 batchUpdate call, got %d", len(batchCalls))
536611
}
537612
reqs := batchCalls[0].Requests
538-
if len(reqs) != 5 {
539-
t.Fatalf("expected delete, insert, reset, code text style, and code shading requests, got %#v", reqs)
613+
if len(reqs) != 7 {
614+
t.Fatalf("expected delete, insert, text/paragraph/bullet resets, code text style, and code shading requests, got %#v", reqs)
540615
}
541616
if got := reqs[1].InsertText; got == nil || got.Location.Index != 7 || got.Text != "\nline1"+docsSoftLineBreak+"line2\n" {
542617
t.Fatalf("unexpected insert request: %#v", got)
543618
}
544-
if got := reqs[3].UpdateTextStyle; got == nil || got.Range.StartIndex != 8 || got.Range.EndIndex != 20 {
619+
if got := reqs[5].UpdateTextStyle; got == nil || got.Range.StartIndex != 8 || got.Range.EndIndex != 20 {
545620
t.Fatalf("unexpected code text style request: %#v", got)
546621
} else {
547622
assertFencedCodeTextStyle(t, got)
548623
}
549-
if got := reqs[4].UpdateParagraphStyle; got == nil || got.Range.StartIndex != 8 || got.Range.EndIndex != 20 {
624+
if got := reqs[6].UpdateParagraphStyle; got == nil || got.Range.StartIndex != 8 || got.Range.EndIndex != 20 {
550625
t.Fatalf("unexpected code shading request: %#v", got)
551626
}
552627
}

internal/cmd/docs_mutation.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.
136136
baseIndex++
137137
}
138138
formattingRequests, textToInsert, tables := MarkdownToDocsRequests(elements, baseIndex, tabID)
139-
if markdownRangeReplacementIsInline(cleaned, elements) {
139+
inlineReplacement := markdownRangeReplacementIsInline(cleaned, elements)
140+
if inlineReplacement {
140141
textToInsert = strings.TrimSuffix(textToInsert, "\n")
141142
}
142143

@@ -161,6 +162,13 @@ func replaceDocsMarkdownRange(ctx context.Context, svc *docs.Service, doc *docs.
161162
},
162163
})
163164
requests = append(requests, resetDocsTextStyleRequest(baseIndex, baseIndex+utf16Len(textToInsert), tabID))
165+
if !inlineReplacement {
166+
resetEnd := baseIndex + utf16Len(textToInsert)
167+
if docRangeCoversParagraphText(doc, startIdx, endIdx, tabID) {
168+
resetEnd++
169+
}
170+
requests = append(requests, resetDocsParagraphRequests(baseIndex, resetEnd, tabID)...)
171+
}
164172
requests = append(requests, formattingRequests...)
165173
}
166174

@@ -210,6 +218,28 @@ func markdownRangeReplacementIsInline(markdown string, elements []MarkdownElemen
210218
elements[0].Type == MDParagraph
211219
}
212220

221+
func resetDocsParagraphRequests(startIdx, endIdx int64, tabID string) []*docs.Request {
222+
rng := &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID}
223+
return []*docs.Request{
224+
{
225+
DeleteParagraphBullets: &docs.DeleteParagraphBulletsRequest{
226+
Range: rng,
227+
},
228+
},
229+
{
230+
UpdateParagraphStyle: &docs.UpdateParagraphStyleRequest{
231+
Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID},
232+
ParagraphStyle: &docs.ParagraphStyle{
233+
NamedStyleType: docsNamedStyleNormalText,
234+
IndentStart: &docs.Dimension{Magnitude: 0, Unit: "PT"},
235+
IndentFirstLine: &docs.Dimension{Magnitude: 0, Unit: "PT"},
236+
},
237+
Fields: "namedStyleType,indentStart,indentFirstLine",
238+
},
239+
},
240+
}
241+
}
242+
213243
func markdownReplaceNeedsParagraphBoundary(doc *docs.Document, startIdx int64, tabID string, elements []MarkdownElement) bool {
214244
return markdownAppendNeedsParagraphBoundary(elements) && !docRangeStartsParagraph(doc, startIdx, tabID)
215245
}
@@ -431,6 +461,44 @@ func docRangeStartsParagraph(doc *docs.Document, startIdx int64, tabID string) b
431461
return bodyHasParagraphStart(doc.Body, startIdx)
432462
}
433463

464+
func docRangeCoversParagraphText(doc *docs.Document, startIdx, endIdx int64, tabID string) bool {
465+
if doc == nil {
466+
return false
467+
}
468+
if tabID != "" {
469+
tab, err := findTab(flattenTabs(doc.Tabs), tabID)
470+
if err != nil || tab.DocumentTab == nil {
471+
return false
472+
}
473+
return elementsContainParagraphTextRange(tab.DocumentTab.Body.Content, startIdx, endIdx)
474+
}
475+
if doc.Body == nil {
476+
return false
477+
}
478+
return elementsContainParagraphTextRange(doc.Body.Content, startIdx, endIdx)
479+
}
480+
481+
func elementsContainParagraphTextRange(elements []*docs.StructuralElement, startIdx, endIdx int64) bool {
482+
for _, el := range elements {
483+
if el == nil {
484+
continue
485+
}
486+
if el.Paragraph != nil && paragraphTextStart(el) == startIdx && el.EndIndex == endIdx+1 {
487+
return true
488+
}
489+
if el.Table != nil {
490+
for _, row := range el.Table.TableRows {
491+
for _, cell := range row.TableCells {
492+
if elementsContainParagraphTextRange(cell.Content, startIdx, endIdx) {
493+
return true
494+
}
495+
}
496+
}
497+
}
498+
}
499+
return false
500+
}
501+
434502
func bodyHasParagraphStart(body *docs.Body, startIdx int64) bool {
435503
if body == nil {
436504
return false

0 commit comments

Comments
 (0)