Add promisemirror markdown capabilities
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
40
cmd/proboctl/main.go
Normal file
40
cmd/proboctl/main.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
32
pkg/cmd/documentversion/documentversion.go
Normal file
32
pkg/cmd/documentversion/documentversion.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 <command>",
|
||||
Short: "Manage document versions",
|
||||
}
|
||||
|
||||
cmd.AddCommand(updatecontent.NewCmdUpdateContent(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
140
pkg/cmd/documentversion/updatecontent/updatecontent.go
Normal file
140
pkg/cmd/documentversion/updatecontent/updatecontent.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 <version-id> --content '{"type":"doc",...}'
|
||||
|
||||
# Update from markdown
|
||||
prb document-version update-content --id <version-id> --from-markdown "# Hello"
|
||||
|
||||
# Update from stdin
|
||||
cat content.json | prb document-version update-content --id <version-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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
77
pkg/proboctl/mdtoprosemirror/mdtoprosemirror.go
Normal file
77
pkg/proboctl/mdtoprosemirror/mdtoprosemirror.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
35
pkg/proboctl/root/root.go
Normal file
35
pkg/proboctl/root/root.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 <command> [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
|
||||
}
|
||||
453
pkg/prosemirror/markdown.go
Normal file
453
pkg/prosemirror/markdown.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
396
pkg/prosemirror/markdown_test.go
Normal file
396
pkg/prosemirror/markdown_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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("")
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user