diff --git a/pkg/prosemirror/markdown_converter.go b/pkg/prosemirror/markdown_converter.go index f75495bf4..eb43b79dd 100644 --- a/pkg/prosemirror/markdown_converter.go +++ b/pkg/prosemirror/markdown_converter.go @@ -55,6 +55,20 @@ func ParseMarkdown(markdown string) (Node, error) { }, nil } +// normalizeCodeBlockContent strips one trailing newline when it is only the +// line terminator goldmark attaches to the last content line. That avoids an +// extra visible blank line in editors (e.g. TipTap) while preserving a +// trailing blank line in the source, which ends with two newlines. +func normalizeCodeBlockContent(content string) string { + if content == "" { + return content + } + if strings.HasSuffix(content, "\n") && !strings.HasSuffix(content, "\n\n") { + return strings.TrimSuffix(content, "\n") + } + return content +} + type converter struct { source []byte marks []Mark @@ -238,7 +252,7 @@ func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) ([]Node, erro buf.Write(line.Value(c.source)) } - content := buf.String() + content := normalizeCodeBlockContent(buf.String()) var lang *string if n.Language(c.source) != nil { @@ -274,7 +288,7 @@ func (c *converter) convertCodeBlock(n *ast.CodeBlock) ([]Node, error) { buf.Write(line.Value(c.source)) } - content := buf.String() + content := normalizeCodeBlockContent(buf.String()) attrs, err := json.Marshal(CodeBlockAttrs{Language: nil}) if err != nil { diff --git a/pkg/prosemirror/markdown_converter_test.go b/pkg/prosemirror/markdown_converter_test.go index 9d5dbb973..e943e18b3 100644 --- a/pkg/prosemirror/markdown_converter_test.go +++ b/pkg/prosemirror/markdown_converter_test.go @@ -167,7 +167,7 @@ func TestParseMarkdown_CodeBlock(t *testing.T) { assert.Equal(t, "go", *attrs.Language) require.Len(t, cb.Content, 1) - assert.Equal(t, "fmt.Println(\"hello\")\n", *cb.Content[0].Text) + assert.Equal(t, "fmt.Println(\"hello\")", *cb.Content[0].Text) }) t.Run("without language", func(t *testing.T) { @@ -183,6 +183,21 @@ func TestParseMarkdown_CodeBlock(t *testing.T) { attrs, err := cb.CodeBlockAttrs() require.NoError(t, err) assert.Nil(t, attrs.Language) + + require.Len(t, cb.Content, 1) + assert.Equal(t, "some code", *cb.Content[0].Text) + }) + + t.Run("trailing blank line preserved", func(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("```\nline\n\n```") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + cb := doc.Content[0] + require.Len(t, cb.Content, 1) + assert.Equal(t, "line\n\n", *cb.Content[0].Text) }) } diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index ae6cde51e..a7dcbcb8f 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -2067,12 +2067,17 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest trustCenterVisibility = input.TrustCenterVisibility } + contentJSON, err := markdownToProseMirrorJSON(input.Content) + if err != nil { + panic(fmt.Errorf("cannot convert markdown to prosemirror: %w", err)) + } + document, documentVersion, err := svc.Documents.Create( ctx, probo.CreateDocumentRequest{ OrganizationID: input.OrganizationID, Title: input.Title, - Content: input.Content, + Content: contentJSON, Classification: input.Classification, DocumentType: input.DocumentType, TrustCenterVisibility: trustCenterVisibility, @@ -2082,7 +2087,12 @@ func (r *Resolver) AddDocumentTool(ctx context.Context, req *mcp.CallToolRequest panic(fmt.Errorf("cannot create document: %w", err)) } - return nil, types.NewAddDocumentOutput(document, documentVersion), nil + out, err := types.NewAddDocumentOutput(document, documentVersion) + if err != nil { + panic(fmt.Errorf("cannot build add document output: %w", err)) + } + + return nil, out, nil } func (r *Resolver) UpdateDocumentTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateDocumentInput) (*mcp.CallToolResult, types.UpdateDocumentOutput, error) { @@ -2129,7 +2139,12 @@ func (r *Resolver) ListDocumentVersionsTool(ctx context.Context, req *mcp.CallTo panic(fmt.Errorf("cannot list document versions: %w", err)) } - return nil, types.NewListDocumentVersionsOutput(versionPage), nil + out, err := types.NewListDocumentVersionsOutput(versionPage) + if err != nil { + panic(fmt.Errorf("cannot build list document versions output: %w", err)) + } + + return nil, out, nil } func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetDocumentVersionInput) (*mcp.CallToolResult, types.GetDocumentVersionOutput, error) { @@ -2142,8 +2157,13 @@ func (r *Resolver) GetDocumentVersionTool(ctx context.Context, req *mcp.CallTool panic(fmt.Errorf("cannot get document version: %w", err)) } + dv, err := types.NewDocumentVersion(version) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.GetDocumentVersionOutput{ - DocumentVersion: types.NewDocumentVersion(version), + DocumentVersion: dv, }, nil } @@ -2175,8 +2195,13 @@ func (r *Resolver) CreateDraftDocumentVersionTool(ctx context.Context, req *mcp. } } + dv, err := types.NewDocumentVersion(draftVersion) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.CreateDraftDocumentVersionOutput{ - DocumentVersion: types.NewDocumentVersion(draftVersion), + DocumentVersion: dv, }, nil } @@ -2207,8 +2232,13 @@ func (r *Resolver) UpdateDocumentVersionTool(ctx context.Context, req *mcp.CallT panic(fmt.Errorf("cannot update document version: %w", err)) } + dv, err := types.NewDocumentVersion(documentVersion) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.UpdateDocumentVersionOutput{ - DocumentVersion: types.NewDocumentVersion(documentVersion), + DocumentVersion: dv, }, nil } @@ -3835,8 +3865,13 @@ func (r *Resolver) RequestDocumentVersionApprovalTool(ctx context.Context, req * panic(fmt.Errorf("cannot get document version: %w", err)) } + dv, err := types.NewDocumentVersion(documentVersion) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.RequestDocumentVersionApprovalOutput{ - DocumentVersion: types.NewDocumentVersion(documentVersion), + DocumentVersion: dv, }, nil } @@ -3856,9 +3891,14 @@ func (r *Resolver) PublishMajorDocumentVersionTool(ctx context.Context, req *mcp panic(fmt.Errorf("cannot publish major document version: %w", err)) } + dv, err := types.NewDocumentVersion(documentVersion) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.PublishMajorDocumentVersionOutput{ Document: types.NewDocument(document), - DocumentVersion: types.NewDocumentVersion(documentVersion), + DocumentVersion: dv, }, nil } @@ -3878,9 +3918,14 @@ func (r *Resolver) PublishMinorDocumentVersionTool(ctx context.Context, req *mcp panic(fmt.Errorf("cannot publish minor document version: %w", err)) } + dv, err := types.NewDocumentVersion(documentVersion) + if err != nil { + panic(fmt.Errorf("cannot build document version: %w", err)) + } + return nil, types.PublishMinorDocumentVersionOutput{ Document: types.NewDocument(document), - DocumentVersion: types.NewDocumentVersion(documentVersion), + DocumentVersion: dv, }, nil } diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 595b12141..e564274ee 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -5327,7 +5327,7 @@ components: description: Document type content: type: string - description: Document content + description: Document body in markdown (converted from stored ProseMirror JSON) changelog: type: string description: Changelog @@ -5481,7 +5481,7 @@ components: description: Document title content: type: string - description: Document content + description: Document content in markdown format classification: $ref: "#/components/schemas/DocumentClassification" description: Document classification @@ -5494,6 +5494,7 @@ components: AddDocumentOutput: type: object + description: Created document and version; document_version.content is markdown required: - document - document_version @@ -5580,6 +5581,7 @@ components: ListDocumentVersionsOutput: type: object + description: Each document_versions[].content is markdown required: - document_versions properties: @@ -5602,6 +5604,7 @@ components: GetDocumentVersionOutput: type: object + description: document_version.content is markdown required: - document_version properties: @@ -5622,6 +5625,7 @@ components: CreateDraftDocumentVersionOutput: type: object + description: Created draft; document_version.content is markdown required: - document_version properties: @@ -5648,6 +5652,7 @@ components: UpdateDocumentVersionOutput: type: object + description: Updated draft; document_version.content is markdown required: - document_version properties: @@ -5698,6 +5703,7 @@ components: PublishDocumentVersionOutput: type: object + description: document_version.content is markdown required: - document - document_version @@ -5727,6 +5733,7 @@ components: RequestDocumentVersionApprovalOutput: type: object + description: document_version.content is markdown required: - document_version properties: diff --git a/pkg/server/api/mcp/v1/types/document.go b/pkg/server/api/mcp/v1/types/document.go index 6d485d995..53089e9b3 100644 --- a/pkg/server/api/mcp/v1/types/document.go +++ b/pkg/server/api/mcp/v1/types/document.go @@ -15,10 +15,32 @@ package types import ( + "fmt" + "strings" + "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/page" + "go.probo.inc/probo/pkg/prosemirror" ) +func proseMirrorJSONToMarkdown(pmJSON string) (string, error) { + if strings.TrimSpace(pmJSON) == "" { + return "", nil + } + + node, err := prosemirror.Parse(pmJSON) + if err != nil { + return "", fmt.Errorf("cannot parse prosemirror json: %w", err) + } + + md, err := prosemirror.RenderMarkdown(node) + if err != nil { + return "", fmt.Errorf("cannot render markdown: %w", err) + } + + return md, nil +} + func NewDocument(d *coredata.Document) *Document { return &Document{ ID: d.ID, @@ -88,14 +110,24 @@ func NewListDocumentsOutput(documentPage *page.Page[*coredata.Document, coredata } } -func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) AddDocumentOutput { +func NewAddDocumentOutput(doc *coredata.Document, docVersion *coredata.DocumentVersion) (AddDocumentOutput, error) { + dv, err := NewDocumentVersion(docVersion) + if err != nil { + return AddDocumentOutput{}, err + } + return AddDocumentOutput{ Document: NewDocument(doc), - DocumentVersion: NewDocumentVersion(docVersion), - } + DocumentVersion: dv, + }, nil } -func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion { +func NewDocumentVersion(dv *coredata.DocumentVersion) (*DocumentVersion, error) { + contentMD, err := proseMirrorJSONToMarkdown(dv.Content) + if err != nil { + return nil, fmt.Errorf("cannot convert document version content to markdown: %w", err) + } + return &DocumentVersion{ ID: dv.ID, OrganizationID: dv.OrganizationID, @@ -105,19 +137,23 @@ func NewDocumentVersion(dv *coredata.DocumentVersion) *DocumentVersion { Minor: dv.Minor, Classification: dv.Classification, DocumentType: dv.DocumentType, - Content: dv.Content, + Content: contentMD, Changelog: dv.Changelog, Status: dv.Status, PublishedAt: dv.PublishedAt, CreatedAt: dv.CreatedAt, UpdatedAt: dv.UpdatedAt, - } + }, nil } -func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) ListDocumentVersionsOutput { +func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVersion, coredata.DocumentVersionOrderField]) (ListDocumentVersionsOutput, error) { versions := make([]*DocumentVersion, 0, len(versionPage.Data)) for _, v := range versionPage.Data { - versions = append(versions, NewDocumentVersion(v)) + dv, err := NewDocumentVersion(v) + if err != nil { + return ListDocumentVersionsOutput{}, err + } + versions = append(versions, dv) } var nextCursor *page.CursorKey @@ -129,7 +165,7 @@ func NewListDocumentVersionsOutput(versionPage *page.Page[*coredata.DocumentVers return ListDocumentVersionsOutput{ NextCursor: nextCursor, DocumentVersions: versions, - } + }, nil } func NewDocumentVersionSignature(dvs *coredata.DocumentVersionSignature) *DocumentVersionSignature {