pax_global_header00006660000000000000000000000064147573637710014536gustar00rootroot0000000000000052 comment=f26d8ad033e3f6fc6e9b50e26c887226abf502bd goportabletext-0.1.0/000077500000000000000000000000001475736377100145775ustar00rootroot00000000000000goportabletext-0.1.0/.github/000077500000000000000000000000001475736377100161375ustar00rootroot00000000000000goportabletext-0.1.0/.github/FUNDING.yml000066400000000000000000000000151475736377100177500ustar00rootroot00000000000000github: [bep]goportabletext-0.1.0/.github/workflows/000077500000000000000000000000001475736377100201745ustar00rootroot00000000000000goportabletext-0.1.0/.github/workflows/test.yml000066400000000000000000000020201475736377100216700ustar00rootroot00000000000000on: push: branches: [main] pull_request: name: Test jobs: test: strategy: matrix: go-version: [1.23.x, 1.24.x] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - name: Install Go uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: go-version: ${{ matrix.go-version }} - name: Install staticcheck run: go install honnef.co/go/tools/cmd/staticcheck@latest shell: bash - name: Update PATH run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH shell: bash - name: Checkout code uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Fmt if: matrix.platform != 'windows-latest' # :( run: "diff <(gofmt -d .) <(printf '')" shell: bash - name: Vet run: go vet ./... - name: Staticcheck run: staticcheck ./... - name: Test run: go test -race ./... goportabletext-0.1.0/.gitignore000066400000000000000000000004261475736377100165710ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out gen/gen # Dependency directories (remove the comment below to include it) # vendor/ goportabletext-0.1.0/LICENSE000066400000000000000000000020651475736377100156070ustar00rootroot00000000000000MIT License Copyright (c) 2022 Bjørn Erik Pedersen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. goportabletext-0.1.0/README.md000066400000000000000000000017121475736377100160570ustar00rootroot00000000000000[![Tests on Linux, MacOS and Windows](https://github.com/bep/goportabletext/workflows/Test/badge.svg)](https://github.com/bep/goportabletext/actions?query=workflow:Test) [![Go Report Card](https://goreportcard.com/badge/github.com/bep/goportabletext)](https://goreportcard.com/report/github.com/bep/goportabletext) [![GoDoc](https://godoc.org/github.com/bep/goportabletext?status.svg)](https://godoc.org/github.com/bep/goportabletext) Converts [Portable Text](https://www.portabletext.org/) to Markdown. ## Types supported * `block` and `span` * `image`. Note that the image handling is currently very simple; we link to the `asset.url` using `asset.altText` as the image alt text and `asset.title` as the title. * `code` (see the [code-input](https://www.sanity.io/plugins/code-input) plugin). Code will be rendered as [fenced code blocks](https://gohugo.io/contribute/documentation/#fenced-code-blocks) with any filename provided passed on as a markdown attribute. goportabletext-0.1.0/go.mod000066400000000000000000000005111475736377100157020ustar00rootroot00000000000000module github.com/bep/goportabletext go 1.23 require ( github.com/go-quicktest/qt v1.101.0 github.com/mitchellh/mapstructure v1.5.0 ) require ( github.com/google/go-cmp v0.5.9 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect ) goportabletext-0.1.0/go.sum000066400000000000000000000024401475736377100157320ustar00rootroot00000000000000github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= goportabletext-0.1.0/internal/000077500000000000000000000000001475736377100164135ustar00rootroot00000000000000goportabletext-0.1.0/internal/portabletext/000077500000000000000000000000001475736377100211305ustar00rootroot00000000000000goportabletext-0.1.0/internal/portabletext/portabletext.go000066400000000000000000000124471475736377100242040ustar00rootroot00000000000000// Copyright 2025 Bjørn Erik Pedersen // SPDX-License-Identifier: MIT package portabletext import ( "bufio" "encoding/json" "fmt" "io" "unicode" "github.com/mitchellh/mapstructure" ) type Type string const ( TypeBlock Type = "block" TypeSpan Type = "span" ) type Blocks []Block type BaseBlock struct { // Key is a unique identifier for the block. Key string `json:"_key" mapstructure:"_key"` // The type makes it possible for a serializer to parse the contents of the block. Type Type `json:"_type" mapstructure:"_type"` } // A Block is a top-level structure in a Portable Text array. type Block struct { BaseBlock `mapstructure:",squash"` // Union type. // A block can be either a text block or an image block. // The type field is used to determine which type of block it is. Text `mapstructure:",squash"` Image `mapstructure:",squash"` Code `mapstructure:",squash"` } type Style string type Text struct { // Children is an array of spans or custom inline types that is contained within a block. Children []Child `json:"children" mapstructure:"children"` // MarkDefs definitions is an array of objects with a key, type and some data. // Mark definitions are tied to spans by adding the referring _key in the marks array. MarkDefs []MarkDef `json:"markDefs" mapstructure:"markDefs"` // Style typically describes a visual property for the whole block. Style Style `json:"style,omitempty" mapstructure:"style"` // Level is used to express visual nesting and hierarchical structures between blocks in the array. Level int `json:"level,omitempty" mapstructure:"level"` // A block can be given the property listItem with a value that describes which kind of list it is. // Typically bullet, number, square and so on. // The list position is derived from the position the block has in the array and surrounding list items on the same level. ListItem string `json:"listItem,omitempty" mapstructure:"listItem"` } func (b Block) HasText() bool { for _, child := range b.Children { if child.Text != "" { return true } } return false } type Image struct { Asset Asset `json:"asset" mapstructure:"asset"` } type Asset struct { ID string `json:"_id" mapstructure:"_id"` AltText string `json:"altText" mapstructure:"altText"` Description string `json:"description" mapstructure:"description"` Metadata struct { Dimensions struct { AspectRatio float64 `json:"aspectRatio" mapstructure:"aspectRatio"` Height int `json:"height" mapstructure:"height"` Width int `json:"width" mapstructure:"width"` } `json:"dimensions"` } `json:"metadata"` Path string `json:"path" mapstructure:"path"` Title string `json:"title" mapstructure:"title"` URL string `json:"url" mapstructure:"url"` } type Code struct { Code string `json:"code" mapstructure:"code"` Filename string `json:"filename" mapstructure:"filename"` Language string `json:"language" mapstructure:"language"` } // A Child is a span or custom inline type that is contained within a block. // A span is the standard way to express inline text within a block type Child struct { // The type makes it possible for a serializer to parse the contents of the block. Type Type `json:"_type" mapstructure:"_type"` // Marks are how we mark up inline text with additional data/features. // Marks comes in two forms: Decorators and Annotations. // Decorators are marks as simple string values, while Annotations are keys to a data structure. // marks is therefore either an array of string values, or keys, corresponding to markDefs, with the same _key. Marks []string `json:"marks" mapstructure:"marks"` // The text contents of the span. Text string `json:"text" mapstructure:"text"` } type MarkDef struct { Key string `json:"_key" mapstructure:"_key"` Type Type `json:"_type" mapstructure:"_type"` Href string `json:"href" mapstructure:"href"` } // The Parse function reads a Portable Text block or array from the src. // The src aan be either a io.Reader or a ... TODO1 func Parse(src any) (Blocks, error) { switch v := src.(type) { case io.Reader: return parseReader(v) case map[string]any: return parseMap(v) case []any: return parseSlice(v) default: panic(fmt.Sprintf("unsupported type %T", src)) } } func parseMap(m map[string]any) (Blocks, error) { var block Block if err := mapstructure.Decode(m, &block); err != nil { return nil, err } return Blocks{block}, nil } func parseSlice(s []any) (Blocks, error) { var blocks Blocks if err := mapstructure.Decode(s, &blocks); err != nil { return nil, err } return blocks, nil } func parseReader(r io.Reader) (Blocks, error) { // Check if r represents an array of blocks or a single block. // We need to buffer the input to determine this. buff := bufio.NewReader(r) var firstNonSpace rune for { c, _, err := buff.ReadRune() if err != nil { return nil, err } if !unicode.IsSpace(c) { firstNonSpace = c buff.UnreadRune() break } } switch firstNonSpace { case '[': // Parse the array of blocks. var blocks Blocks if err := json.NewDecoder(buff).Decode(&blocks); err != nil { return nil, err } return blocks, nil case '{': // Parse a single block. var block Block if err := json.NewDecoder(buff).Decode(&block); err != nil { return nil, err } return Blocks{block}, nil default: return nil, nil } } goportabletext-0.1.0/internal/portabletext/portabletext_test.go000066400000000000000000000013441475736377100252350ustar00rootroot00000000000000// Copyright 2025 Bjørn Erik Pedersen // SPDX-License-Identifier: MIT package portabletext import ( "strings" "testing" "github.com/go-quicktest/qt" ) const singleSpan = ` { "_key": "R5FvMrjo", "_type": "block", "children": [ { "_key": "cZUQGmh4", "_type": "span", "marks": [], "text": "Plain text." } ], "markDefs": [], "style": "normal" }` func TestParseBlocks(t *testing.T) { blocks, err := Parse(strings.NewReader(singleSpan)) qt.Assert(t, qt.IsNil(err)) qt.Assert(t, qt.DeepEquals(blocks, Blocks{ { BaseBlock: BaseBlock{Key: "R5FvMrjo", Type: "block"}, Text: Text{ Children: []Child{{Type: "span", Marks: []string{}, Text: "Plain text."}}, MarkDefs: []MarkDef{}, Style: "normal", }, }, })) } goportabletext-0.1.0/internal/ptesting/000077500000000000000000000000001475736377100202505ustar00rootroot00000000000000goportabletext-0.1.0/internal/ptesting/testhelpers.go000066400000000000000000000103321475736377100231400ustar00rootroot00000000000000// Copyright 2025 Bjørn Erik Pedersen // SPDX-License-Identifier: MIT package ptesting import ( "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" ) // IsCI reports whether the tests are running in a CI environment. func IsCI() bool { return os.Getenv("CI") != "" } type BlockContentToMarkdownHelper interface { SkipFiles() map[string]bool GenerateGolden() error ForEachGoldenPair(func(sourceName, targetName, sourceContent, targetContent string) error) error } func NewUpstreamBlockContentToMarkdownHelper() (BlockContentToMarkdownHelper, error) { return NewBlockContentToMarkdownHelper("testdata/upstream", "testdata/golden_upstream/markdown") } func NewSamplesBlockContentToMarkdownHelper() (BlockContentToMarkdownHelper, error) { return NewBlockContentToMarkdownHelper("testdata/samples", "testdata/golden_samples/markdown") } func NewBlockContentToMarkdownHelper(sourceDir, targetDir string) (BlockContentToMarkdownHelper, error) { _, bb, _, _ := runtime.Caller(0) workingDir := filepath.Dir(bb) baseDir := filepath.Join(workingDir, "..", "..") sourceDir = filepath.Join(baseDir, sourceDir) targetDir = filepath.Join(baseDir, targetDir) b := &blockContentToMarkdown{ workingDir: workingDir, SourceDir: sourceDir, TargetDir: targetDir, } return b, b.init() } // blockContentToMarkdown is a test helper that converts JSON block content files to Markdown. type blockContentToMarkdown struct { workingDir string SourceDir string TargetDir string } func (b *blockContentToMarkdown) SkipFiles() map[string]bool { skipFilesFilename := filepath.Join(b.SourceDir, "skipfiles.txt") skipFilesContent, err := os.ReadFile(skipFilesFilename) if err != nil { panic(err) } skipFiles := make(map[string]bool) for _, file := range strings.Split(string(skipFilesContent), "\n") { skipFiles[strings.TrimSpace(file)] = true } return skipFiles } func (b *blockContentToMarkdown) ForEachGoldenPair(fn func(sourceName, targetName, sourceContent, targetContent string) error) error { skipFiles := b.SkipFiles() sourceDirFiles, err := os.ReadDir(b.SourceDir) if err != nil { return err } for _, file := range sourceDirFiles { if skipFiles[file.Name()] || !strings.HasSuffix(file.Name(), ".json") { continue } sourceName := file.Name() targetName := sourceName + ".md" sourceFilename := filepath.Join(b.SourceDir, sourceName) targetFilename := filepath.Join(b.TargetDir, targetName) sourceContent, err := os.ReadFile(sourceFilename) if err != nil { } targetContent, err := os.ReadFile(targetFilename) if err != nil { if errors.Is(err, os.ErrNotExist) { // Create the target file and fill it with some content that will fail the test. targetContent = []byte("FAIL") if err := os.WriteFile(targetFilename, targetContent, 0o644); err != nil { return err } } return err } if err := fn(sourceName, targetName, string(sourceContent), string(targetContent)); err != nil { return err } } return nil } func (b *blockContentToMarkdown) init() error { if b.SourceDir == "" || b.TargetDir == "" { return errors.New("sourceDir and targetDir must be set") } return nil } func (b *blockContentToMarkdown) GenerateGolden() error { // Read all JSON files in sourceDir. // For each JSON file, convert it to Markdown. // Write the Markdown to targetDir. sourceDirFiles, err := os.ReadDir(b.SourceDir) if err != nil { return err } // Clear out the targetDir. if err := os.RemoveAll(b.TargetDir); err != nil { return err } if err := os.MkdirAll(b.TargetDir, 0o755); err != nil { return err } skipFiles := b.SkipFiles() for _, file := range sourceDirFiles { if skipFiles[file.Name()] || !strings.HasSuffix(file.Name(), ".json") { continue } filename := filepath.Join(b.SourceDir, file.Name()) outFilename := filepath.Join(b.TargetDir, file.Name()+".md") if err := func() error { outFile, err := os.Create(outFilename) if err != nil { return err } defer outFile.Close() fmt.Println(filename) script := filepath.Join(b.workingDir, "block-to-md.js") cmd := exec.Command(script, filename) cmd.Dir = b.workingDir cmd.Stdout = outFile cmd.Stderr = os.Stderr return cmd.Run() }(); err != nil { return err } } return nil } goportabletext-0.1.0/markdown.go000066400000000000000000000213221475736377100167500ustar00rootroot00000000000000// Copyright 2025 Bjørn Erik Pedersen // SPDX-License-Identifier: MIT package goportabletext import ( "fmt" "io" "strings" "sync" "github.com/bep/goportabletext/internal/portabletext" ) const ( newline = "\n" indentOne = " " ) var newlineb = []byte(newline) // ToMarkdown converts the given Portable Text blocks in opts.Src to Markdown and writes the result to opts.Dst. func ToMarkdown(opts ToMarkdownOptions) error { blocks, ok := opts.Src.(portabletext.Blocks) if !ok { var err error blocks, err = portabletext.Parse(opts.Src) if err != nil { return err } } mw := getWriter() mw.dst = opts.Dst mw.src = blocks defer putWriter(mw) return mw.write() } // ToMarkdownOptions provides options for the ToMarkdown function. type ToMarkdownOptions struct { // The destination writer. Dst io.Writer // The source Portable Text blocks. // Can be either // * a io.Reader with a JSON value or array of blocks. // * map[string]any or a []any. // * a portabletext.Blocks slice (used in benchmarks only). Src any } type markdownWriter struct { dst io.Writer src portabletext.Blocks err error // parser state. currentListItem string markDefs map[string]portabletext.MarkDef marksOpen [][]string marksClose [][]string } func (m *markdownWriter) setListState(block portabletext.Block) { if block.ListItem == "" { m.currentListItem = "" } if block.Level == 1 { if block.ListItem != "" && (m.currentListItem == "" || m.currentListItem != block.ListItem) { m.currentListItem = block.ListItem } } } func (m *markdownWriter) indent(level int) string { if level <= 1 { return "" } return strings.Repeat(indentOne, level-1) } func (m *markdownWriter) indentText(s string, level int) string { if level == 1 { return s } indent := m.indent(level) if m.currentListItem != "" { // Get the indentation in line with the list item. switch m.currentListItem { case "number": indent += " " default: indent += " " } } i := strings.Index(s, "\n") if i == -1 { return s } var k int var sb strings.Builder for { j := strings.IndexFunc(s[i+k+1:], func(r rune) bool { return r != '\n' }) if j == -1 { break } sb.WriteString(s[k : k+i+j+1]) sb.WriteString(indent) k += i + j + 1 i = strings.Index(s[k:], "\n") if i == -1 { break } } if k < len(s) { sb.WriteString(s[k:]) } return sb.String() } // We always insert a newline after a block. This method writes a newline // before a block if needed. func (m *markdownWriter) newlineBeforeBlockIfNeeded(i int) { if i == 0 { return } prev := m.src[i-1] current := m.src[i] if func() bool { if !current.HasText() { return false } // E.g. from paragraph to list. if prev.ListItem == "" && current.ListItem != "" { return true } // New list. if current.Level == 1 && current.ListItem != "" && m.currentListItem != current.ListItem { return true } if prev.ListItem != "" && current.ListItem != "" { return false } return true }() { m.writeNewline() } } func (m *markdownWriter) write() error { if len(m.src) == 0 { m.writeNewline() return nil } for i, block := range m.src { m.newlineBeforeBlockIfNeeded(i) m.setListState(block) wasBlock := m.writeBlock(block) if m.err != nil { return m.err } // There may be simpler way to do this, but this // effectively trims all but one newline at the end. if wasBlock || i < len(m.src)-1 || len(m.src) == 1 { m.writeNewline() } } return m.err } func (m *markdownWriter) writeBlock(b portabletext.Block) bool { switch b.Type { case "image": m.writeImage(b.Image) return true case "code": m.writeCode(b.Code) return true } if !b.HasText() { return false } clear(m.markDefs) size := len(b.Children) // Resizable slice of marks. if cap(m.marksOpen) < size { m.marksOpen = make([][]string, 0, size+10) m.marksClose = make([][]string, 0, size+10) } m.marksOpen = m.marksOpen[:size] m.marksClose = m.marksClose[:size] m.clearMarks() for i, c := range b.Children { atStart := i == 0 atEnd := i == size-1 var next, prev portabletext.Child if !atEnd { next = b.Children[i+1] } if !atStart { prev = b.Children[i-1] } for _, mark := range c.Marks { inPrev, inNext := in(mark, prev.Marks), in(mark, next.Marks) if !inPrev { m.marksOpen[i] = append(m.marksOpen[i], mark) } if !inNext { m.marksClose[i] = append(m.marksClose[i], mark) } } } for _, md := range b.MarkDefs { m.markDefs[md.Key] = md } m.writeStyle(b) m.writeList(b) for i := range b.Children { m.writeChild(i, b.Level, b.Children) if m.err != nil { return false } } return true } func (m *markdownWriter) writeChild(i, level int, children []portabletext.Child) { c := children[i] s := c.Text // In the Portable Text editor, a common case is that e.g. bold text also covers whitespace // on either side. This works fine when rendering HTML, but not so well in Markdown. // E.g. this is not valid Markdown: _** italic and bold. **_ i1 := strings.IndexFunc(s, func(r rune) bool { return r != ' ' }) i2 := strings.LastIndexFunc(s, func(r rune) bool { return r != ' ' }) var s1, s2, s3 string if i1 > 0 { s1 = s[:i1] } if i2 > 0 { s3 = s[i2+1:] } if i1 > 0 && i2 > 0 { s2 = s[i1 : i2+1] } else if i1 > 0 { s2 = s[i1:] } else if i2 > 0 { s2 = s[:i2+1] } else { s2 = s } if s1 != "" { m.writeString(s1) } m.writeMarks(true, i) m.writeString(m.indentText(s2, level)) m.writeMarks(false, i) if s3 != "" { m.writeString(s3) } } func (m *markdownWriter) writeCode(c portabletext.Code) { m.writeString("```") m.writeString(c.Language) if c.Filename != "" { // Add as markdown attribute. m.writeString(fmt.Sprintf(" {filename=%q}", c.Filename)) } m.writeNewline() m.writeString(c.Code) m.writeNewline() m.writeString("```") } func (m *markdownWriter) writeImage(img portabletext.Image) { m.writeNewline() m.writeString(fmt.Sprintf("![%s](%s)", img.Asset.AltText, img.Asset.URL)) } func (m *markdownWriter) writeIndent(level int) { m.writeString(m.indent(level)) } func (m *markdownWriter) writeList(b portabletext.Block) { if b.ListItem == "" { return } m.writeIndent(b.Level) switch b.ListItem { case "bullet": m.writeString("* ") case "number": m.writeString("1. ") // Auto numbering is provided by the editor/renderer. case "square": m.writeString("- ") default: m.writeString("* ") } } func (m *markdownWriter) writeMark(start bool, s string) { switch s { case "strong": m.writeString("**") case "code": m.writeString("`") case "em": m.writeString("_") case "underline": // Not supported in Markdown. m.writeString("") case "strike-through": m.writeString("~~") default: // Try to find the markDef. md, found := m.markDefs[s] if !found { return } switch md.Type { case "link": if start { m.writeString("[") } else { m.writeString("](") m.writeString(md.Href) m.writeString(")") } default: panic("unsupported mark type: " + md.Type) } } } func (m *markdownWriter) writeMarks(start bool, i int) { if start { for _, mark := range m.marksOpen[i] { m.writeMark(start, mark) } } else { // Reverse order. for j := len(m.marksClose[i]) - 1; j >= 0; j-- { m.writeMark(start, m.marksClose[i][j]) } } } func (m *markdownWriter) writeNewline() { m.dst.Write(newlineb) } func (m *markdownWriter) writeString(s string) { _, err := io.WriteString(m.dst, s) if err != nil { m.err = err } } func (m *markdownWriter) writeStyle(b portabletext.Block) { style := b.Style switch style { case "normal": case "": // Do nothing. case "blockquote": m.writeString("> ") case "code": m.writeString("```") case "pre": m.writeString("```") default: if b.ListItem != "" { return } switch style { case "h1": m.writeString("# ") case "h2": m.writeString("## ") case "h3": m.writeString("### ") case "h4": m.writeString("#### ") case "h5": m.writeString("##### ") case "h6": m.writeString("###### ") default: return } } } func (m *markdownWriter) clear() { m.dst = nil m.src = nil m.err = nil m.currentListItem = "" clear(m.markDefs) m.clearMarks() } func (m *markdownWriter) clearMarks() { for i := range m.marksOpen { m.marksOpen[i] = m.marksOpen[i][:0] } for i := range m.marksClose { m.marksClose[i] = m.marksClose[i][:0] } } func in(e string, s []string) bool { for _, v := range s { if v == e { return true } } return false } var writerPool = sync.Pool{ New: func() any { return &markdownWriter{ markDefs: make(map[string]portabletext.MarkDef), } }, } func getWriter() *markdownWriter { return writerPool.Get().(*markdownWriter) } func putWriter(w *markdownWriter) { w.clear() writerPool.Put(w) } goportabletext-0.1.0/markdown_test.go000066400000000000000000000045161475736377100200150ustar00rootroot00000000000000// Copyright 2025 Bjørn Erik Pedersen // SPDX-License-Identifier: MIT package goportabletext_test import ( "encoding/json" "io" "strings" "testing" "github.com/bep/goportabletext" "github.com/bep/goportabletext/internal/portabletext" "github.com/bep/goportabletext/internal/ptesting" "github.com/go-quicktest/qt" ) func TestToMarkdownSamples(t *testing.T) { // Used during develoopment. pinnedName := "" testHelper(t, newSamplesHelper(t), pinnedName) } func BenchmarkToMarkdown(b *testing.B) { bh := func(h ptesting.BlockContentToMarkdownHelper) { h.ForEachGoldenPair(func(sourceName, targetName, sourceContent, targetContent string) error { blocks, err := portabletext.Parse(strings.NewReader(sourceContent)) if err != nil { b.Fatal(err) } opts := goportabletext.ToMarkdownOptions{ Dst: io.Discard, Src: blocks, } b.Run(sourceName, func(b *testing.B) { for i := 0; i < b.N; i++ { err := goportabletext.ToMarkdown(opts) if err != nil { b.Fatal(err) } } }) return nil }) } bh(newSamplesHelper(b)) } func newSamplesHelper(t testing.TB) ptesting.BlockContentToMarkdownHelper { b, err := ptesting.NewSamplesBlockContentToMarkdownHelper() qt.Assert(t, qt.IsNil(err)) return b } func dosToUnix(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") } func jsonToMap(s string) any { var m any if err := json.Unmarshal([]byte(s), &m); err != nil { panic(err) } return m } func testHelper(t *testing.T, h ptesting.BlockContentToMarkdownHelper, pinnedName string) { t.Helper() if ptesting.IsCI() { // Make sure we run all tests in CI. pinnedName = "" } err := h.ForEachGoldenPair(func(sourceName, targetName, sourceContent, targetContent string) error { if pinnedName != "" && sourceName != pinnedName { return nil } checkSrc := func(src any) { var buff strings.Builder opts := goportabletext.ToMarkdownOptions{ Dst: &buff, Src: src, } qt.Assert(t, qt.IsNil(goportabletext.ToMarkdown(opts))) result := dosToUnix(buff.String()) targetContent = dosToUnix(targetContent) qt.Assert(t, qt.Equals(result, targetContent), qt.Commentf("input:\n%s\nexpected:\n%s\ngot:\n%s", sourceName, targetName, result)) } checkSrc(strings.NewReader(sourceContent)) checkSrc(jsonToMap(sourceContent)) return nil }) qt.Assert(t, qt.IsNil(err)) } goportabletext-0.1.0/testdata/000077500000000000000000000000001475736377100164105ustar00rootroot00000000000000goportabletext-0.1.0/testdata/golden_samples/000077500000000000000000000000001475736377100214045ustar00rootroot00000000000000goportabletext-0.1.0/testdata/golden_samples/markdown/000077500000000000000000000000001475736377100232265ustar00rootroot00000000000000goportabletext-0.1.0/testdata/golden_samples/markdown/100-list-indent.json.md000066400000000000000000000003341475736377100272500ustar00rootroot000000000000001. Li1 1. Li2 1. Li2-1 Some more text in Li2-1. Even more text. A new paragraph in Li2-1. * Bullet 1 * Bullet2 * Bullet2 indented. Some more text. Even more text. A paragraph. goportabletext-0.1.0/testdata/golden_samples/markdown/101-mark-issue-1.json.md000066400000000000000000000000041475736377100272270ustar00rootroot00000000000000FAILgoportabletext-0.1.0/testdata/golden_samples/markdown/101-marks-variations.json.md000066400000000000000000000001411475736377100303050ustar00rootroot00000000000000This is **bold.** This is _**italic and bold.**_ This is a [**bold link**](https://example.org). goportabletext-0.1.0/testdata/golden_samples/markdown/102-post-with-image.json.md000066400000000000000000000002061475736377100300340ustar00rootroot00000000000000Sunrise: ![Sunrise Alt Text](https://cdn.sanity.io/images/myproject/production/7826651cc48ccf102252a7f86d4dcb66c198284c-480x300.jpg) goportabletext-0.1.0/testdata/golden_samples/markdown/103-code.json.md000066400000000000000000000000701475736377100257300ustar00rootroot00000000000000```css {filename="foo.css"} body { color: blue; } ``` goportabletext-0.1.0/testdata/golden_samples/markdown/104-styles.json.md000066400000000000000000000001561475736377100263470ustar00rootroot00000000000000# Heading 1 ## Heading 2 ### Heading 3 #### Heading 4 ##### Heading 5 ###### Heading 6 > A block quote. goportabletext-0.1.0/testdata/samples/000077500000000000000000000000001475736377100200545ustar00rootroot00000000000000goportabletext-0.1.0/testdata/samples/100-list-indent.json000066400000000000000000000035021475736377100234770ustar00rootroot00000000000000[ { "_key": "7c3175d95f4f", "_type": "block", "children": [ { "_key": "bdde49aeda85", "_type": "span", "marks": [], "text": "Li1" } ], "level": 1, "listItem": "number", "markDefs": [], "style": "normal" }, { "_key": "fa2c2396a597", "_type": "block", "children": [ { "_key": "f40bf22c54d1", "_type": "span", "marks": [], "text": "Li2" } ], "level": 1, "listItem": "number", "markDefs": [], "style": "normal" }, { "_key": "641b997043de", "_type": "block", "children": [ { "_key": "74cf77c95747", "_type": "span", "marks": [], "text": "Li2-1\n\nSome more text in Li2-1.\nEven more text.\n\nA new paragraph in Li2-1." } ], "level": 2, "listItem": "number", "markDefs": [], "style": "normal" }, { "_key": "7c166c6834d7", "_type": "block", "children": [ { "_key": "cba9dc53c863", "_type": "span", "marks": [], "text": "Bullet 1" } ], "level": 1, "listItem": "bullet", "markDefs": [], "style": "normal" }, { "_key": "b25aa1d06ba5", "_type": "block", "children": [ { "_key": "1d047eb9a249", "_type": "span", "marks": [], "text": "Bullet2" } ], "level": 1, "listItem": "bullet", "markDefs": [], "style": "normal" }, { "_key": "7467a347490f", "_type": "block", "children": [ { "_key": "6a42ca7f8b1b", "_type": "span", "marks": [], "text": "Bullet2 indented.\n\nSome more text.\nEven more text.\n\nA paragraph." } ], "level": 2, "listItem": "bullet", "markDefs": [], "style": "normal" } ] goportabletext-0.1.0/testdata/samples/101-marks-variations.json000066400000000000000000000021731475736377100245430ustar00rootroot00000000000000[ { "_key": "a0f5f4395feb", "_type": "block", "children": [ { "_key": "4b3e0f79ce71", "_type": "span", "marks": [], "text": "This is " }, { "_key": "4506c348ba6a", "_type": "span", "marks": ["strong"], "text": "bold." }, { "_key": "4c64dd0d1e6f", "_type": "span", "marks": [], "text": " This is" }, { "_key": "b11f33418423", "_type": "span", "marks": ["em", "strong"], "text": " italic and bold. " }, { "_key": "cf10ecafb9fb", "_type": "span", "marks": [], "text": "This is a " }, { "_key": "507c2d165074", "_type": "span", "marks": ["1d217bfd4fa0", "strong"], "text": "bold link" }, { "_key": "5105a564b66c", "_type": "span", "marks": [], "text": "." } ], "markDefs": [ { "_key": "1d217bfd4fa0", "_type": "link", "href": "https://example.org" } ], "style": "normal" } ] goportabletext-0.1.0/testdata/samples/102-post-with-image.json000066400000000000000000000015531475736377100242710ustar00rootroot00000000000000[ { "_key": "c701427e2a76", "_type": "block", "children": [ { "_key": "c6e05c0cb8c7", "_type": "span", "marks": [], "text": "Sunrise:" } ], "markDefs": [], "style": "normal" }, { "_key": "f78bf8ce4854", "_type": "image", "asset": { "_id": "image-7826651cc48ccf102252a7f86d4dcb66c198284c-480x300-jpg", "altText": "Sunrise Alt Text", "description": "Sunrise Description", "metadata": { "dimensions": { "aspectRatio": 1.6, "height": 300, "width": 480 } }, "path": "images/myproject/production/7826651cc48ccf102252a7f86d4dcb66c198284c-480x300.jpg", "title": "Sunrise Title", "url": "https://cdn.sanity.io/images/myproject/production/7826651cc48ccf102252a7f86d4dcb66c198284c-480x300.jpg" } } ] goportabletext-0.1.0/testdata/samples/103-code.json000066400000000000000000000006141475736377100221630ustar00rootroot00000000000000[ { "_key": "5dadc87540cb", "_type": "code", "code": "body {\n color: blue;\n}", "filename": "foo.css", "language": "css" }, { "_key": "fee43313306f", "_type": "block", "children": [ { "_key": "0dd47c96668a", "_type": "span", "marks": ["code"], "text": "" } ], "markDefs": [], "style": "normal" } ] goportabletext-0.1.0/testdata/samples/104-styles.json000066400000000000000000000036531475736377100226030ustar00rootroot00000000000000[ { "_key": "91a055ce54ac", "_type": "block", "children": [ { "_key": "e7988f53b9e1", "_type": "span", "marks": [], "text": "Heading 1" } ], "markDefs": [], "style": "h1" }, { "_key": "c0fb02a1185b", "_type": "block", "children": [ { "_key": "473672d9c6b2", "_type": "span", "marks": [], "text": "Heading 2" } ], "markDefs": [], "style": "h2" }, { "_key": "c3cffa5134da", "_type": "block", "children": [ { "_key": "b180a40e735c", "_type": "span", "marks": [], "text": "Heading 3" } ], "markDefs": [], "style": "h3" }, { "_key": "e2a81790191f", "_type": "block", "children": [ { "_key": "8174d8c847db", "_type": "span", "marks": [], "text": "Heading 4" } ], "markDefs": [], "style": "h4" }, { "_key": "4138c96daafd", "_type": "block", "children": [ { "_key": "ee6346d2d210", "_type": "span", "marks": [], "text": "Heading 5" } ], "markDefs": [], "style": "h5" }, { "_key": "2da86fa1cb19", "_type": "block", "children": [ { "_key": "7483f5d96fd0", "_type": "span", "marks": [], "text": "Heading 6" } ], "markDefs": [], "style": "h6" }, { "_key": "49019caeb64b", "_type": "block", "children": [ { "_key": "279e4d8cdbd2", "_type": "span", "marks": [], "text": "A block quote." } ], "markDefs": [], "style": "blockquote" }, { "_key": "2362dac804e9", "_type": "block", "children": [ { "_key": "ade607e894be", "_type": "span", "marks": [], "text": "" } ], "markDefs": [], "style": "normal" } ] goportabletext-0.1.0/testdata/samples/skipfiles.txt000066400000000000000000000000011475736377100225750ustar00rootroot00000000000000#