diff --git a/cmd/proboctl/main.go b/cmd/proboctl/main.go new file mode 100644 index 000000000..da0797938 --- /dev/null +++ b/cmd/proboctl/main.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "fmt" + "os" + + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/iostreams" + "go.probo.inc/probo/pkg/proboctl/root" +) + +func main() { + ios := iostreams.System() + ios.ApplyColorProfile() + + f := &cmdutil.Factory{ + IOStreams: ios, + } + + cmd := root.NewCmdRoot(f) + + if err := cmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 733e7d778..6c388bc85 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.32 github.com/vikstrous/dataloadgen v0.0.10 + github.com/yuin/goldmark v1.4.13 go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 go.gearno.de/kit v0.1.1 go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c diff --git a/go.sum b/go.sum index 12cd050f3..a7c5df02a 100644 --- a/go.sum +++ b/go.sum @@ -322,6 +322,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc= go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg= go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE= diff --git a/pkg/cmd/documentversion/documentversion.go b/pkg/cmd/documentversion/documentversion.go new file mode 100644 index 000000000..8a19a8f53 --- /dev/null +++ b/pkg/cmd/documentversion/documentversion.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package documentversion + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/cmd/documentversion/updatecontent" +) + +func NewCmdDocumentVersion(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "document-version ", + Short: "Manage document versions", + } + + cmd.AddCommand(updatecontent.NewCmdUpdateContent(f)) + + return cmd +} diff --git a/pkg/cmd/documentversion/updatecontent/updatecontent.go b/pkg/cmd/documentversion/updatecontent/updatecontent.go new file mode 100644 index 000000000..243384c38 --- /dev/null +++ b/pkg/cmd/documentversion/updatecontent/updatecontent.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package updatecontent + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/prosemirror" +) + +const updateContentMutation = ` +mutation($input: UpdateDocumentVersionContentInput!) { + updateDocumentVersionContent(input: $input) { + content + } +} +` + +type updateContentResponse struct { + UpdateDocumentVersionContent struct { + Content string `json:"content"` + } `json:"updateDocumentVersionContent"` +} + +func NewCmdUpdateContent(f *cmdutil.Factory) *cobra.Command { + var ( + flagID string + flagContent string + flagFromMarkdown string + ) + + cmd := &cobra.Command{ + Use: "update-content", + Short: "Update document version content", + Example: ` # Update with ProseMirror JSON + prb document-version update-content --id --content '{"type":"doc",...}' + + # Update from markdown + prb document-version update-content --id --from-markdown "# Hello" + + # Update from stdin + cat content.json | prb document-version update-content --id `, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + ) + + var content string + switch { + case flagFromMarkdown != "": + doc, err := prosemirror.ParseMarkdown(flagFromMarkdown) + if err != nil { + return err + } + out, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("cannot marshal prosemirror document: %w", err) + } + content = string(out) + case flagContent != "": + content = flagContent + default: + data, err := io.ReadAll(f.IOStreams.In) + if err != nil { + return fmt.Errorf("cannot read from stdin: %w", err) + } + content = string(data) + } + + input := map[string]any{ + "id": flagID, + "content": content, + } + + data, err := client.Do( + updateContentMutation, + map[string]any{"input": input}, + ) + if err != nil { + return err + } + + var resp updateContentResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + _, _ = fmt.Fprintf( + f.IOStreams.Out, + "Updated document version content %s\n", + flagID, + ) + + return nil + }, + } + + cmd.Flags().StringVar(&flagID, "id", "", "Document version ID (required)") + cmd.Flags().StringVar(&flagContent, "content", "", "ProseMirror JSON content") + cmd.Flags().StringVar( + &flagFromMarkdown, + "from-markdown", + "", + "Markdown content to convert and upload", + ) + + _ = cmd.MarkFlagRequired("id") + + return cmd +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 55fe74ba6..c0f33aaac 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,6 +25,7 @@ import ( cmdconfig "go.probo.inc/probo/pkg/cmd/config" cmdcontext "go.probo.inc/probo/pkg/cmd/context" "go.probo.inc/probo/pkg/cmd/control" + "go.probo.inc/probo/pkg/cmd/documentversion" "go.probo.inc/probo/pkg/cmd/evidence" "go.probo.inc/probo/pkg/cmd/finding" "go.probo.inc/probo/pkg/cmd/framework" @@ -74,6 +75,7 @@ func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdconfig.NewCmdConfig(f)) cmd.AddCommand(cmdcontext.NewCmdContext(f)) cmd.AddCommand(control.NewCmdControl(f)) + cmd.AddCommand(documentversion.NewCmdDocumentVersion(f)) cmd.AddCommand(evidence.NewCmdEvidence(f)) cmd.AddCommand(finding.NewCmdFinding(f)) cmd.AddCommand(framework.NewCmdFramework(f)) diff --git a/pkg/proboctl/mdtoprosemirror/mdtoprosemirror.go b/pkg/proboctl/mdtoprosemirror/mdtoprosemirror.go new file mode 100644 index 000000000..297347f23 --- /dev/null +++ b/pkg/proboctl/mdtoprosemirror/mdtoprosemirror.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package mdtoprosemirror + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/prosemirror" +) + +func NewCmdMdToProsemirror(f *cmdutil.Factory) *cobra.Command { + var flagContent string + + cmd := &cobra.Command{ + Use: "md-to-prosemirror", + Short: "Convert markdown to ProseMirror JSON", + Example: ` # Convert from flag + proboctl md-to-prosemirror --content "# Hello" + + # Convert from stdin + echo "# Hello" | proboctl md-to-prosemirror + + # Convert from file + proboctl md-to-prosemirror < document.md`, + RunE: func(cmd *cobra.Command, args []string) error { + var input string + if flagContent != "" { + input = flagContent + } else { + data, err := io.ReadAll(f.IOStreams.In) + if err != nil { + return fmt.Errorf("cannot read from stdin: %w", err) + } + input = string(data) + } + + doc, err := prosemirror.ParseMarkdown(input) + if err != nil { + return err + } + + out, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("cannot marshal prosemirror document: %w", err) + } + + fmt.Fprintln(f.IOStreams.Out, string(out)) + + return nil + }, + } + + cmd.Flags().StringVar( + &flagContent, + "content", + "", + "Markdown content to convert (reads from stdin if not provided)", + ) + + return cmd +} diff --git a/pkg/proboctl/root/root.go b/pkg/proboctl/root/root.go new file mode 100644 index 000000000..2e049fa0c --- /dev/null +++ b/pkg/proboctl/root/root.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package root + +import ( + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cmd/cmdutil" + "go.probo.inc/probo/pkg/proboctl/mdtoprosemirror" +) + +func NewCmdRoot(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "proboctl [flags]", + Short: "Probo admin CLI", + Long: "proboctl is a command-line tool for Probo administrative operations.", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand(mdtoprosemirror.NewCmdMdToProsemirror(f)) + + return cmd +} diff --git a/pkg/prosemirror/markdown.go b/pkg/prosemirror/markdown.go new file mode 100644 index 000000000..01531177c --- /dev/null +++ b/pkg/prosemirror/markdown.go @@ -0,0 +1,453 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package prosemirror + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + goldmarkext "github.com/yuin/goldmark/extension" + goldmarkast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// ParseMarkdown converts a markdown string into a ProseMirror Node tree. +func ParseMarkdown(markdown string) (Node, error) { + source := []byte(markdown) + + md := goldmark.New( + goldmark.WithExtensions(goldmarkext.Strikethrough), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + ) + + doc := md.Parser().Parse(text.NewReader(source)) + + c := &converter{source: source} + + nodes, err := c.convertChildren(doc) + if err != nil { + return Node{}, fmt.Errorf("cannot convert markdown to prosemirror: %w", err) + } + + return Node{ + Type: NodeDoc, + Content: nodes, + }, nil +} + +type converter struct { + source []byte + marks []Mark +} + +func (c *converter) convertChildren(n ast.Node) ([]Node, error) { + var nodes []Node + + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + converted, err := c.convertNode(child) + if err != nil { + return nil, err + } + nodes = append(nodes, converted...) + } + + return nodes, nil +} + +func (c *converter) convertNode(n ast.Node) ([]Node, error) { + switch n.Kind() { + case ast.KindHeading: + return c.convertHeading(n.(*ast.Heading)) + case ast.KindParagraph: + return c.convertParagraph(n) + case ast.KindBlockquote: + return c.convertBlockquote(n) + case ast.KindFencedCodeBlock: + return c.convertFencedCodeBlock(n.(*ast.FencedCodeBlock)) + case ast.KindCodeBlock: + return c.convertCodeBlock(n.(*ast.CodeBlock)) + case ast.KindList: + return c.convertList(n.(*ast.List)) + case ast.KindListItem: + return c.convertListItem(n) + case ast.KindThematicBreak: + return []Node{{Type: NodeHorizontalRule}}, nil + case ast.KindImage: + return c.convertImage(n.(*ast.Image)) + case ast.KindTextBlock: + return c.convertParagraph(n) + case ast.KindText: + return c.convertText(n.(*ast.Text)) + case ast.KindString: + return c.convertString(n.(*ast.String)) + case ast.KindEmphasis: + return c.convertEmphasis(n.(*ast.Emphasis)) + case ast.KindCodeSpan: + return c.convertCodeSpan(n) + case ast.KindLink: + return c.convertLink(n.(*ast.Link)) + case ast.KindAutoLink: + return c.convertAutoLink(n.(*ast.AutoLink)) + case ast.KindRawHTML: + return c.convertRawHTML(n) + case ast.KindHTMLBlock: + return nil, nil + default: + if n.Kind() == goldmarkast.KindStrikethrough { + return c.convertStrikethrough(n) + } + return nil, fmt.Errorf("cannot convert markdown node of kind %s", n.Kind()) + } +} + +func (c *converter) convertHeading(n *ast.Heading) ([]Node, error) { + children, err := c.convertChildren(n) + if err != nil { + return nil, err + } + + attrs, err := json.Marshal(HeadingAttrs{Level: n.Level}) + if err != nil { + return nil, fmt.Errorf("cannot marshal heading attrs: %w", err) + } + + return []Node{{ + Type: NodeHeading, + Content: children, + Attrs: attrs, + }}, nil +} + +func (c *converter) convertParagraph(n ast.Node) ([]Node, error) { + children, err := c.convertChildren(n) + if err != nil { + return nil, err + } + + return []Node{{ + Type: NodeParagraph, + Content: children, + }}, nil +} + +func (c *converter) convertBlockquote(n ast.Node) ([]Node, error) { + children, err := c.convertChildren(n) + if err != nil { + return nil, err + } + + return []Node{{ + Type: NodeBlockquote, + Content: children, + }}, nil +} + +func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) ([]Node, error) { + var buf bytes.Buffer + + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + buf.Write(line.Value(c.source)) + } + + content := buf.String() + + var lang *string + if n.Language(c.source) != nil { + l := string(n.Language(c.source)) + lang = &l + } + + attrs, err := json.Marshal(CodeBlockAttrs{Language: lang}) + if err != nil { + return nil, fmt.Errorf("cannot marshal code block attrs: %w", err) + } + + var textNodes []Node + if content != "" { + textNodes = []Node{{ + Type: NodeText, + Text: &content, + }} + } + + return []Node{{ + Type: NodeCodeBlock, + Content: textNodes, + Attrs: attrs, + }}, nil +} + +func (c *converter) convertCodeBlock(n *ast.CodeBlock) ([]Node, error) { + var buf bytes.Buffer + + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + buf.Write(line.Value(c.source)) + } + + content := buf.String() + + attrs, err := json.Marshal(CodeBlockAttrs{Language: nil}) + if err != nil { + return nil, fmt.Errorf("cannot marshal code block attrs: %w", err) + } + + var textNodes []Node + if content != "" { + textNodes = []Node{{ + Type: NodeText, + Text: &content, + }} + } + + return []Node{{ + Type: NodeCodeBlock, + Content: textNodes, + Attrs: attrs, + }}, nil +} + +func (c *converter) convertList(n *ast.List) ([]Node, error) { + children, err := c.convertChildren(n) + if err != nil { + return nil, err + } + + if n.IsOrdered() { + attrs, err := json.Marshal(OrderedListAttrs{Start: n.Start}) + if err != nil { + return nil, fmt.Errorf("cannot marshal ordered list attrs: %w", err) + } + return []Node{{ + Type: NodeOrderedList, + Content: children, + Attrs: attrs, + }}, nil + } + + return []Node{{ + Type: NodeBulletList, + Content: children, + }}, nil +} + +func (c *converter) convertListItem(n ast.Node) ([]Node, error) { + children, err := c.convertChildren(n) + if err != nil { + return nil, err + } + + return []Node{{ + Type: NodeListItem, + Content: children, + }}, nil +} + +func (c *converter) convertImage(n *ast.Image) ([]Node, error) { + imgAttrs := ImageAttrs{ + Src: string(n.Destination), + } + + if n.Title != nil { + t := string(n.Title) + imgAttrs.Title = &t + } + + // Collect alt text from child text nodes. + var altBuf bytes.Buffer + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + if child.Kind() == ast.KindText { + altBuf.Write(child.(*ast.Text).Segment.Value(c.source)) + } + } + if altBuf.Len() > 0 { + alt := altBuf.String() + imgAttrs.Alt = &alt + } + + attrs, err := json.Marshal(imgAttrs) + if err != nil { + return nil, fmt.Errorf("cannot marshal image attrs: %w", err) + } + + return []Node{{ + Type: NodeImage, + Attrs: attrs, + }}, nil +} + +func (c *converter) convertText(n *ast.Text) ([]Node, error) { + content := string(n.Segment.Value(c.source)) + if content == "" { + return nil, nil + } + + nodes := []Node{{ + Type: NodeText, + Text: &content, + Marks: copyMarks(c.marks), + }} + + if n.HardLineBreak() { + nodes = append(nodes, Node{Type: NodeHardBreak}) + } + + return nodes, nil +} + +func (c *converter) convertString(n *ast.String) ([]Node, error) { + content := string(n.Value) + if content == "" { + return nil, nil + } + + return []Node{{ + Type: NodeText, + Text: &content, + Marks: copyMarks(c.marks), + }}, nil +} + +func (c *converter) convertEmphasis(n *ast.Emphasis) ([]Node, error) { + var mark Mark + if n.Level == 2 { + mark = Mark{Type: MarkStrong} + } else { + mark = Mark{Type: MarkEm} + } + + c.marks = append(c.marks, mark) + children, err := c.convertChildren(n) + c.marks = c.marks[:len(c.marks)-1] + if err != nil { + return nil, err + } + + return children, nil +} + +func (c *converter) convertCodeSpan(n ast.Node) ([]Node, error) { + var buf bytes.Buffer + + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + if t, ok := child.(*ast.Text); ok { + buf.Write(t.Segment.Value(c.source)) + } + } + + content := buf.String() + if content == "" { + return nil, nil + } + + marks := copyMarks(c.marks) + marks = append(marks, Mark{Type: MarkCode}) + + return []Node{{ + Type: NodeText, + Text: &content, + Marks: marks, + }}, nil +} + +func (c *converter) convertLink(n *ast.Link) ([]Node, error) { + linkAttrs := LinkAttrs{ + Href: string(n.Destination), + } + + if n.Title != nil { + t := string(n.Title) + linkAttrs.Title = &t + } + + attrs, err := json.Marshal(linkAttrs) + if err != nil { + return nil, fmt.Errorf("cannot marshal link attrs: %w", err) + } + + c.marks = append(c.marks, Mark{Type: MarkLink, Attrs: attrs}) + children, err := c.convertChildren(n) + c.marks = c.marks[:len(c.marks)-1] + if err != nil { + return nil, err + } + + return children, nil +} + +func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) { + url := string(n.URL(c.source)) + + linkAttrs := LinkAttrs{Href: url} + attrs, err := json.Marshal(linkAttrs) + if err != nil { + return nil, fmt.Errorf("cannot marshal link attrs: %w", err) + } + + label := string(n.Label(c.source)) + + return []Node{{ + Type: NodeText, + Text: &label, + Marks: append(copyMarks(c.marks), Mark{Type: MarkLink, Attrs: attrs}), + }}, nil +} + +func (c *converter) convertRawHTML(n ast.Node) ([]Node, error) { + var buf bytes.Buffer + + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + buf.Write(line.Value(c.source)) + } + + content := buf.String() + if content == "" { + return nil, nil + } + + return []Node{{ + Type: NodeText, + Text: &content, + Marks: copyMarks(c.marks), + }}, nil +} + +func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) { + c.marks = append(c.marks, Mark{Type: MarkStrike}) + children, err := c.convertChildren(n) + c.marks = c.marks[:len(c.marks)-1] + if err != nil { + return nil, err + } + + return children, nil +} + +func copyMarks(marks []Mark) []Mark { + if len(marks) == 0 { + return nil + } + + cp := make([]Mark, len(marks)) + copy(cp, marks) + + return cp +} diff --git a/pkg/prosemirror/markdown_test.go b/pkg/prosemirror/markdown_test.go new file mode 100644 index 000000000..5b9c4f9ae --- /dev/null +++ b/pkg/prosemirror/markdown_test.go @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package prosemirror + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMarkdown_EmptyInput(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("") + require.NoError(t, err) + assert.Equal(t, NodeDoc, doc.Type) + assert.Empty(t, doc.Content) +} + +func TestParseMarkdown_Paragraph(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("Hello world") + require.NoError(t, err) + assert.Equal(t, NodeDoc, doc.Type) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + assert.Equal(t, NodeParagraph, p.Type) + require.Len(t, p.Content, 1) + assert.Equal(t, NodeText, p.Content[0].Type) + assert.Equal(t, "Hello world", *p.Content[0].Text) +} + +func TestParseMarkdown_Headings(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + markdown string + level int + }{ + {"h1", "# Heading 1", 1}, + {"h2", "## Heading 2", 2}, + {"h3", "### Heading 3", 3}, + {"h4", "#### Heading 4", 4}, + {"h5", "##### Heading 5", 5}, + {"h6", "###### Heading 6", 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown(tt.markdown) + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + h := doc.Content[0] + assert.Equal(t, NodeHeading, h.Type) + + attrs, err := h.HeadingAttrs() + require.NoError(t, err) + assert.Equal(t, tt.level, attrs.Level) + + require.Len(t, h.Content, 1) + assert.Equal(t, NodeText, h.Content[0].Type) + }) + } +} + +func TestParseMarkdown_Bold(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("**bold text**") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "bold text", *txt.Text) + require.Len(t, txt.Marks, 1) + assert.Equal(t, MarkStrong, txt.Marks[0].Type) +} + +func TestParseMarkdown_Italic(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("*italic text*") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "italic text", *txt.Text) + require.Len(t, txt.Marks, 1) + assert.Equal(t, MarkEm, txt.Marks[0].Type) +} + +func TestParseMarkdown_Strikethrough(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("~~deleted~~") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "deleted", *txt.Text) + require.Len(t, txt.Marks, 1) + assert.Equal(t, MarkStrike, txt.Marks[0].Type) +} + +func TestParseMarkdown_InlineCode(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("`code`") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "code", *txt.Text) + require.Len(t, txt.Marks, 1) + assert.Equal(t, MarkCode, txt.Marks[0].Type) +} + +func TestParseMarkdown_CodeBlock(t *testing.T) { + t.Parallel() + + t.Run("with language", func(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("```go\nfmt.Println(\"hello\")\n```") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + cb := doc.Content[0] + assert.Equal(t, NodeCodeBlock, cb.Type) + + attrs, err := cb.CodeBlockAttrs() + require.NoError(t, err) + require.NotNil(t, attrs.Language) + assert.Equal(t, "go", *attrs.Language) + + require.Len(t, cb.Content, 1) + assert.Equal(t, "fmt.Println(\"hello\")\n", *cb.Content[0].Text) + }) + + t.Run("without language", func(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("```\nsome code\n```") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + cb := doc.Content[0] + assert.Equal(t, NodeCodeBlock, cb.Type) + + attrs, err := cb.CodeBlockAttrs() + require.NoError(t, err) + assert.Nil(t, attrs.Language) + }) +} + +func TestParseMarkdown_Link(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("[click here](https://example.com)") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "click here", *txt.Text) + require.Len(t, txt.Marks, 1) + assert.Equal(t, MarkLink, txt.Marks[0].Type) + + linkAttrs, err := txt.Marks[0].LinkAttrs() + require.NoError(t, err) + assert.Equal(t, "https://example.com", linkAttrs.Href) +} + +func TestParseMarkdown_Image(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("![alt text](https://example.com/img.png \"title\")") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + img := p.Content[0] + assert.Equal(t, NodeImage, img.Type) + + attrs, err := img.ImageAttrs() + require.NoError(t, err) + assert.Equal(t, "https://example.com/img.png", attrs.Src) + require.NotNil(t, attrs.Alt) + assert.Equal(t, "alt text", *attrs.Alt) + require.NotNil(t, attrs.Title) + assert.Equal(t, "title", *attrs.Title) +} + +func TestParseMarkdown_BulletList(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("- item 1\n- item 2\n- item 3") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + list := doc.Content[0] + assert.Equal(t, NodeBulletList, list.Type) + require.Len(t, list.Content, 3) + + for i, item := range list.Content { + assert.Equal(t, NodeListItem, item.Type, "item %d", i) + require.Len(t, item.Content, 1) + assert.Equal(t, NodeParagraph, item.Content[0].Type) + } +} + +func TestParseMarkdown_OrderedList(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("1. first\n2. second\n3. third") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + list := doc.Content[0] + assert.Equal(t, NodeOrderedList, list.Type) + + attrs, err := list.OrderedListAttrs() + require.NoError(t, err) + assert.Equal(t, 1, attrs.Start) + + require.Len(t, list.Content, 3) +} + +func TestParseMarkdown_NestedList(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("- parent\n - child\n - child 2\n- parent 2") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + list := doc.Content[0] + assert.Equal(t, NodeBulletList, list.Type) + require.Len(t, list.Content, 2) + + // First item should have a paragraph and a nested bullet list. + firstItem := list.Content[0] + assert.Equal(t, NodeListItem, firstItem.Type) + require.Len(t, firstItem.Content, 2) + assert.Equal(t, NodeParagraph, firstItem.Content[0].Type) + assert.Equal(t, NodeBulletList, firstItem.Content[1].Type) + require.Len(t, firstItem.Content[1].Content, 2) +} + +func TestParseMarkdown_Blockquote(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("> quoted text") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + bq := doc.Content[0] + assert.Equal(t, NodeBlockquote, bq.Type) + require.Len(t, bq.Content, 1) + assert.Equal(t, NodeParagraph, bq.Content[0].Type) +} + +func TestParseMarkdown_HorizontalRule(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("---") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + assert.Equal(t, NodeHorizontalRule, doc.Content[0].Type) +} + +func TestParseMarkdown_HardBreak(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("line one\\\nline two") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + assert.Equal(t, NodeParagraph, p.Type) + + // Should contain: text("line one"), hardBreak, text("line two") + var hasHardBreak bool + for _, child := range p.Content { + if child.Type == NodeHardBreak { + hasHardBreak = true + } + } + assert.True(t, hasHardBreak, "expected hard break node") +} + +func TestParseMarkdown_NestedMarks(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("***bold and italic***") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.Len(t, p.Content, 1) + + txt := p.Content[0] + assert.Equal(t, "bold and italic", *txt.Text) + require.Len(t, txt.Marks, 2) + + markTypes := make(map[MarkType]bool) + for _, m := range txt.Marks { + markTypes[m.Type] = true + } + assert.True(t, markTypes[MarkStrong]) + assert.True(t, markTypes[MarkEm]) +} + +func TestParseMarkdown_MixedContent(t *testing.T) { + t.Parallel() + + doc, err := ParseMarkdown("Normal **bold** and *italic* text") + require.NoError(t, err) + require.Len(t, doc.Content, 1) + + p := doc.Content[0] + require.True(t, len(p.Content) >= 4, "expected at least 4 inline nodes") + + // Verify first text node is plain. + assert.Equal(t, "Normal ", *p.Content[0].Text) + assert.Empty(t, p.Content[0].Marks) + + // Verify bold text node. + assert.Equal(t, "bold", *p.Content[1].Text) + require.Len(t, p.Content[1].Marks, 1) + assert.Equal(t, MarkStrong, p.Content[1].Marks[0].Type) +} + +func TestParseMarkdown_JSONRoundTrip(t *testing.T) { + t.Parallel() + + md := `# Title + +A paragraph with **bold**, *italic*, and ` + "`code`" + `. + +- item 1 +- item 2 + +> blockquote + +--- + +` + "```go\nfmt.Println()\n```" + + doc, err := ParseMarkdown(md) + require.NoError(t, err) + + data, err := json.Marshal(doc) + require.NoError(t, err) + + // Verify we can parse it back. + parsed, err := Parse(string(data)) + require.NoError(t, err) + assert.Equal(t, NodeDoc, parsed.Type) + assert.True(t, len(parsed.Content) > 0) +} diff --git a/prb b/prb new file mode 100755 index 000000000..7f903eed7 Binary files /dev/null and b/prb differ diff --git a/proboctl b/proboctl new file mode 100755 index 000000000..dbcb50b4b Binary files /dev/null and b/proboctl differ