portabletext

package module
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jan 1, 2026 License: MIT Imports: 6 Imported by: 0

README

portabletext

Go Reference Go Report Card GitHub release

A Go library for parsing, validating, and manipulating Portable Text - the JSON-based rich text specification from Sanity.io.

Features

  • Complete Portable Text Support: Handles blocks, spans, marks, mark definitions, and custom objects
  • Preserves Unknown Fields: Captures custom fields in a Raw map for full round-trip fidelity
  • Type-Safe API: Strongly-typed structs with pointer fields for proper null handling
  • Path-Aware Errors: Detailed error messages showing exactly where parsing/validation failed
  • Flexible Validation: Optional validation with configurable rules
  • Zero Dependencies: Only uses Go standard library
  • Immutable Operations: Transform and filter operations return new documents
  • Thread-Safe Reads: Safe for concurrent reads without synchronization

Installation

import "github.com/derickschaefer/portabletext"
go mod tidy

Quick Start

package main

import (
    "fmt"
    "strings"
    
    "github.com/derickschaefer/portabletext"
)

func main() {
    // Parse Portable Text JSON
    input := `[
        {
            "_type": "block",
            "_key": "block1",
            "style": "h1",
            "children": [
                {
                    "_type": "span",
                    "text": "Hello World",
                    "marks": []
                }
            ],
            "markDefs": []
        }
    ]`
    
    doc, err := portabletext.DecodeString(input)
    if err != nil {
        panic(err)
    }
    
    // Access the data
    for _, node := range doc {
        if node.IsBlock() {
            fmt.Printf("Style: %s\n", node.GetStyle())
            fmt.Printf("Text: %s\n", node.GetText())
        }
    }
    
    // Validate
    if errs := portabletext.Validate(doc); len(errs) > 0 {
        for _, err := range errs {
            fmt.Println(err)
        }
    }
}

Usage

Decoding
// From io.Reader
doc, err := portabletext.Decode(reader)

// From string
doc, err := portabletext.DecodeString(jsonString)
Encoding
// To io.Writer
err := portabletext.Encode(writer, doc)

// To string
jsonString, err := portabletext.EncodeString(doc)
Building Documents Programmatically
// Create a simple block
block := portabletext.NewBlock("normal").
    AddSpan("Hello ", "strong").
    AddSpan("world!")

// Create a custom node
customNode := portabletext.NewNode("myCustomType")
customNode.Raw["customField"] = "value"

// Build a document
doc := portabletext.Document{*block, *customNode}
Validation
// Basic validation
errs := portabletext.Validate(doc)

// Advanced validation with options
opts := portabletext.ValidationOptions{
    RequireKeys:      true,  // Require _key on all blocks
    CheckMarkDefRefs: true,  // Verify marks exist in markDefs
    AllowEmptyText:   false, // Disallow empty text in spans
}
errs := portabletext.ValidateWithOptions(doc, opts)

// Check for specific errors
for _, err := range errs {
    if ve, ok := err.(*portabletext.ValidationError); ok {
        fmt.Printf("Error at %s: %s\n", ve.Path, ve.Message)
    }
}
Walking and Traversing
// Simple walk
err := portabletext.Walk(doc, func(node *portabletext.Node) error {
    fmt.Println(node.Type)
    return nil
})

// Walk with context
err := portabletext.WalkWithContext(doc, func(node *portabletext.Node, ctx portabletext.WalkContext) error {
    fmt.Printf("Node %d (block #%d): %s\n", ctx.Index, ctx.BlockCount, node.Type)
    return nil
})
Filtering and Transforming
// Filter: keep only blocks
blocks := portabletext.Filter(doc, func(n *portabletext.Node) bool {
    return n.IsBlock()
})

// Transform: change all h1 to h2
transformed := portabletext.Transform(doc, func(n *portabletext.Node) *portabletext.Node {
    if n.GetStyle() == "h1" {
        h2 := "h2"
        n.Style = &h2
    }
    return n
})
Working with Nodes
// Check node type
if node.IsBlock() {
    // It's a block
}

// Get style with default
style := node.GetStyle() // returns "normal" if nil

// Get all text from a block
text := node.GetText()

// Get list level with default
level := node.GetListLevel() // returns 1 if nil

// Clone a node (deep copy)
clone := node.Clone()

// Add spans and marks
node.AddSpan("Click ", "strong")
node.AddMarkDef("link1", "link", map[string]any{
    "href": "https://example.com",
})
Working with Spans
for _, span := range node.Children {
    // Check for specific mark
    if span.HasMark("strong") {
        fmt.Println("Bold text:", *span.Text)
    }
    
    // Access custom fields
    if customField, ok := span.Raw["myField"]; ok {
        fmt.Println("Custom:", customField)
    }
}
Error Handling
doc, err := portabletext.Decode(reader)
if err != nil {
    // Check for specific error types
    var pErr *portabletext.Error
    if errors.As(err, &pErr) {
        fmt.Printf("Parse error at %s: %v\n", pErr.Path, pErr.Err)
        
        // Check underlying error
        if errors.Is(pErr.Err, portabletext.ErrMissingType) {
            fmt.Println("Missing _type field")
        }
    }
}

Data Structures

Node

Represents a Portable Text node (typically a block or custom object):

type Node struct {
    Type     string              // Required: "_type"
    Key      string              // Optional: "_key"
    Style    *string             // Block style (e.g., "normal", "h1")
    Children []Span              // Child spans/inline objects
    MarkDefs []MarkDef           // Mark definitions (links, etc.)
    ListItem *string             // List item type
    Level    *int                // List nesting level
    Raw      map[string]any      // Unknown/custom fields
}
Span

Represents an inline element within a block:

type Span struct {
    Type  string              // Required: "_type"
    Text  *string             // Text content
    Marks []string            // Applied marks
    Raw   map[string]any      // Unknown/custom fields
}
MarkDef

Represents a mark definition (annotations like links):

type MarkDef struct {
    Key  string              // Required: "_key"
    Type string              // Required: "_type"
    Raw  map[string]any      // Unknown/custom fields (e.g., href)
}

Examples

Example 1: Extract All Text
func ExtractText(doc portabletext.Document) string {
    var buf strings.Builder
    for _, node := range doc {
        if node.IsBlock() {
            buf.WriteString(node.GetText())
            buf.WriteString("\n")
        }
    }
    return buf.String()
}
func FindLinks(doc portabletext.Document) []string {
    var links []string
    for _, node := range doc {
        for _, md := range node.MarkDefs {
            if md.Type == "link" {
                if href, ok := md.Raw["href"].(string); ok {
                    links = append(links, href)
                }
            }
        }
    }
    return links
}
Example 3: Convert Headings
func DowngradeHeadings(doc portabletext.Document) portabletext.Document {
    return portabletext.Transform(doc, func(n *portabletext.Node) *portabletext.Node {
        if !n.IsBlock() {
            return n
        }
        
        style := n.GetStyle()
        var newStyle string
        switch style {
        case "h1":
            newStyle = "h2"
        case "h2":
            newStyle = "h3"
        case "h3":
            newStyle = "h4"
        default:
            return n
        }
        
        n.Style = &newStyle
        return n
    })
}
Example 4: Custom Validation
func ValidateMaxLength(doc portabletext.Document, maxChars int) error {
    totalChars := 0
    for _, node := range doc {
        if node.IsBlock() {
            totalChars += len(node.GetText())
        }
    }
    
    if totalChars > maxChars {
        return fmt.Errorf("document exceeds %d characters: %d", maxChars, totalChars)
    }
    return nil
}
Example 5: Add Table of Contents
func GenerateTOC(doc portabletext.Document) []TOCEntry {
    var toc []TOCEntry
    
    portabletext.Walk(doc, func(n *portabletext.Node) error {
        style := n.GetStyle()
        if style == "h1" || style == "h2" || style == "h3" {
            toc = append(toc, TOCEntry{
                Level: style,
                Text:  n.GetText(),
                Key:   n.Key,
            })
        }
        return nil
    })
    
    return toc
}

type TOCEntry struct {
    Level string
    Text  string
    Key   string
}

Examples

The examples/ directory contains working CLI tools demonstrating practical use cases:

1. Extract Text (examples/01-extract-text)

Extract plain text with formatting from Portable Text documents:

cd examples/01-extract-text
go run main.go ../basic.json

Output:

# Getting Started with Portable Text

Portable Text is a JSON-based rich text specification...

Extract all links with their associated text and metadata:

cd examples/02-find-links
go run main.go ../basic.json

Output:

[1] Key: link1
    URL: https://portabletext.org
    Text: official documentation
3. Build Document (examples/03-build-document)

Programmatically create Portable Text documents:

cd examples/03-build-document
go run main.go > output.json

Creates a complete document with headings, paragraphs, lists, links, and custom blocks.

4. Transform Headings (examples/04-transform-headings)

Upgrade or downgrade heading levels:

cd examples/04-transform-headings
go run main.go --downgrade --pretty ../basic.json

Converts h1→h2, h2→h3, etc. with pretty-printed JSON output.

5. Table of Contents (examples/05-table-of-contents)

Generate a table of contents from document headings:

cd examples/05-table-of-contents
go run main.go --markdown ../basic.json

Output:

## Table of Contents

- [Getting Started with Portable Text](#getting-started-with-portable-text)
  - [Key Features](#key-features)

See the examples README for more details and additional use cases.

Code Snippets

Quick examples for common operations:

Extract all text:

func ExtractText(doc portabletext.Document) string {
    var buf strings.Builder
    for _, node := range doc {
        if node.IsBlock() {
            buf.WriteString(node.GetText())
            buf.WriteString("\n")
        }
    }
    return buf.String()
}

Find all links:

func FindLinks(doc portabletext.Document) []string {
    var links []string
    for _, node := range doc {
        for _, md := range node.MarkDefs {
            if md.Type == "link" {
                if href, ok := md.Raw["href"].(string); ok {
                    links = append(links, href)
                }
            }
        }
    }
    return links
}

Transform document:

func DowngradeHeadings(doc portabletext.Document) portabletext.Document {
    return portabletext.Transform(doc, func(n *portabletext.Node) *portabletext.Node {
        if n.GetStyle() == "h1" {
            h2 := "h2"
            n.Style = &h2
        }
        return n
    })
}

Portable Text Specification

This library implements the Portable Text specification. Key concepts:

  • Block: A top-level content node (paragraph, heading, etc.)
  • Span: Inline text within a block with optional marks
  • Mark: Text annotation (bold, italic, link, etc.)
  • MarkDef: Definition of a mark with additional data (e.g., link URL)
  • Custom Objects: Any node type beyond the standard spec

Version History

v0.1.1 (Current)
  • Added Key field to Node for _key support
  • Added builder functions: NewBlock(), NewNode()
  • Added convenience methods: GetStyle(), GetText(), GetListLevel(), AddSpan(), AddMarkDef()
  • Added HasMark() method to Span
  • Added DecodeString() / EncodeString() convenience functions
  • Added WalkWithContext() for contextual traversal
  • Added Filter() and Transform() operations
  • Enhanced validation with ValidationOptions and ValidationError
  • Improved error types with structured ValidationError
v0.1.0
  • Initial release
  • Core parsing and encoding
  • Basic validation
  • Error handling with paths
  • Walk functionality
  • Node cloning
  • Raw field preservation

Thread Safety

  • Reads: Documents are safe for concurrent reads without synchronization
  • Writes: Concurrent modifications require external synchronization
  • Immutability: Filter() and Transform() create new documents and are safe to call concurrently

Performance Considerations

  • Decoding uses json.NewDecoder for efficient streaming
  • All operations avoid unnecessary allocations where possible
  • Clone() performs deep copies - use sparingly for large documents
  • Transform() creates a new document - consider in-place modification for very large documents

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT License - see LICENSE file for details.

Support

Documentation

Overview

Package portabletext provides parsing, validation, and manipulation of Portable Text documents.

Portable Text is a JSON-based rich text specification from Sanity.io that represents structured content as an abstract syntax tree (AST). This package provides a complete Go implementation with strong typing, path-aware errors, and flexible validation.

Quick Start

Parse Portable Text from JSON:

input := `[{"_type":"block","children":[{"_type":"span","text":"Hello"}]}]`
doc, err := portabletext.DecodeString(input)
if err != nil {
	log.Fatal(err)
}

Access the parsed content:

for _, node := range doc {
	if node.IsBlock() {
		fmt.Println(node.GetText())
	}
}

Build documents programmatically:

block := portabletext.NewBlock("normal").
	AddSpan("Hello ", "strong").
	AddSpan("world!")
doc := portabletext.Document{*block}

Core Types

The main types are:

  • Document: An ordered list of Portable Text nodes
  • Node: A block or custom object with type, style, children, and marks
  • Span: An inline text element within a block
  • MarkDef: A mark definition (e.g., link with href)

All types preserve unknown/custom fields in a Raw map for full round-trip fidelity.

Decoding and Encoding

Decode from io.Reader or string:

doc, err := portabletext.Decode(reader)
doc, err := portabletext.DecodeString(jsonString)

Encode to io.Writer or string:

err := portabletext.Encode(writer, doc)
jsonString, err := portabletext.EncodeString(doc)

Validation

Basic validation checks for required fields and proper structure:

errs := portabletext.Validate(doc)
for _, err := range errs {
	fmt.Println(err)
}

Advanced validation with custom options:

opts := portabletext.ValidationOptions{
	RequireKeys:      true,  // Require _key on blocks
	CheckMarkDefRefs: true,  // Verify mark references
	AllowEmptyText:   false, // Disallow empty spans
}
errs := portabletext.ValidateWithOptions(doc, opts)

Traversal

Walk all nodes:

err := portabletext.Walk(doc, func(node *portabletext.Node) error {
	fmt.Println(node.Type)
	return nil
})

Walk with context information:

portabletext.WalkWithContext(doc, func(n *portabletext.Node, ctx portabletext.WalkContext) error {
	fmt.Printf("Node %d: %s\n", ctx.Index, n.Type)
	return nil
})

Filtering and Transformation

Filter nodes by predicate:

blocks := portabletext.Filter(doc, func(n *portabletext.Node) bool {
	return n.IsBlock()
})

Transform nodes (returns new document):

transformed := portabletext.Transform(doc, func(n *portabletext.Node) *portabletext.Node {
	if n.GetStyle() == "h1" {
		h2 := "h2"
		n.Style = &h2
	}
	return n
})

Working with Nodes

Node provides convenience methods:

// Check type
if node.IsBlock() { }

// Get values with defaults
style := node.GetStyle()        // "normal" if nil
level := node.GetListLevel()    // 1 if nil
text := node.GetText()          // concatenated span text

// Build fluently
node.AddSpan("text", "strong", "em")
node.AddMarkDef("link1", "link", map[string]any{"href": "https://..."})

// Clone (deep copy)
clone := node.Clone()

Working with Spans

Check for marks:

if span.HasMark("strong") {
	fmt.Println("Bold text:", *span.Text)
}

Access custom fields:

if href, ok := markDef.Raw["href"].(string); ok {
	fmt.Println("Link:", href)
}

Error Handling

Errors include path information for debugging:

doc, err := portabletext.Decode(reader)
if err != nil {
	var pErr *portabletext.Error
	if errors.As(err, &pErr) {
		// pErr.Path shows where the error occurred
		// e.g., "[2].children[1].marks"
		fmt.Printf("Error at %s: %v\n", pErr.Path, pErr.Err)
	}
}

Validation errors provide structured information:

for _, err := range errs {
	if ve, ok := err.(*portabletext.ValidationError); ok {
		fmt.Printf("%s: %s\n", ve.Path, ve.Message)
		// ve.Node provides reference to the problematic node
	}
}

Custom Fields

Unknown fields are preserved in Raw maps:

node.Raw["customField"] = "value"
span.Raw["customAttr"] = 123
markDef.Raw["target"] = "_blank"

These fields are included when encoding back to JSON.

Thread Safety

Documents are safe for concurrent reads without synchronization. Concurrent writes require external synchronization. Filter() and Transform() create new documents and are safe to call concurrently.

Examples

Extract all text:

func ExtractText(doc portabletext.Document) string {
	var buf strings.Builder
	for _, node := range doc {
		if node.IsBlock() {
			buf.WriteString(node.GetText())
			buf.WriteString("\n")
		}
	}
	return buf.String()
}

Find all links:

func FindLinks(doc portabletext.Document) []string {
	var links []string
	for _, node := range doc {
		for _, md := range node.MarkDefs {
			if md.Type == "link" {
				if href, ok := md.Raw["href"].(string); ok {
					links = append(links, href)
				}
			}
		}
	}
	return links
}

Generate table of contents:

func GenerateTOC(doc portabletext.Document) []string {
	var toc []string
	portabletext.Walk(doc, func(n *portabletext.Node) error {
		style := n.GetStyle()
		if style == "h1" || style == "h2" {
			toc = append(toc, n.GetText())
		}
		return nil
	})
	return toc
}

Specification

This package implements the Portable Text specification: https://github.com/portabletext/portabletext

Key concepts:

  • Block: Top-level content node (paragraph, heading, list item, etc.)
  • Span: Inline text within a block, optionally with marks
  • Mark: Text annotation (bold, italic, link, etc.)
  • MarkDef: Mark definition with additional data (e.g., link href)
  • Custom Objects: Extensible beyond standard types

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrMissingType     = errors.New("missing _type")
	ErrInvalidType     = errors.New("invalid _type")
	ErrExpectedObject  = errors.New("expected JSON object")
	ErrExpectedArray   = errors.New("expected JSON array")
	ErrInvalidMarks    = errors.New("marks must be an array of strings")
	ErrInvalidNumber   = errors.New("invalid number")
	ErrUnexpectedToken = errors.New("unexpected JSON token")
)

Functions

func Encode

func Encode(w io.Writer, doc Document) error

Encode serializes the AST back to JSON. - Re-emits all known and unknown fields - Does not mutate the input document

func EncodeString added in v0.1.1

func EncodeString(doc Document) (string, error)

EncodeString is a convenience wrapper for Encode.

func Validate

func Validate(doc Document) []error

Validate performs optional, opt-in checks. Unknown node types are never errors.

func ValidateWithOptions added in v0.1.1

func ValidateWithOptions(doc Document, opts ValidationOptions) []error

ValidateWithOptions performs validation with custom options.

func Walk

func Walk(doc Document, fn func(*Node) error) error

Walk visits all top-level nodes in order; stops early on fn error.

func WalkWithContext added in v0.1.1

func WalkWithContext(doc Document, fn func(*Node, WalkContext) error) error

WalkWithContext visits all top-level nodes with additional context.

Types

type Document

type Document []Node

Document is an ordered list of Portable Text nodes. Document is not safe for concurrent modification. For concurrent reads, no synchronization is needed. For concurrent writes, external synchronization is required.

func Decode

func Decode(r io.Reader) (Document, error)

Decode parses JSON Portable Text into a Document. - Requires _type on all nodes and child spans/markDefs where present - Captures unknown fields into Raw (including explicit nulls) - Does not normalize or semantically validate

func DecodeString added in v0.1.1

func DecodeString(s string) (Document, error)

DecodeString is a convenience wrapper for Decode.

func Filter added in v0.1.1

func Filter(doc Document, pred func(*Node) bool) Document

Filter returns a new document with nodes matching the predicate.

func Transform added in v0.1.1

func Transform(doc Document, fn func(*Node) *Node) Document

Transform applies fn to each node, returning a new document. If fn returns nil, the node is excluded from the result.

type Error

type Error struct {
	Op   string // "decode", "node", "span", "markDef"
	Path string // e.g. "[3].children[1].marks"
	Err  error
}

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type MarkDef

type MarkDef struct {
	Key  string `json:"_key"`
	Type string `json:"_type"`

	Raw map[string]any `json:"-"`
}

MarkDef represents an annotation definition (e.g. link objects).

func (MarkDef) MarshalJSON

func (md MarkDef) MarshalJSON() ([]byte, error)

type Node

type Node struct {
	// Required
	Type string `json:"_type"`
	Key  string `json:"_key,omitempty"`

	// Common block fields
	Style    *string   `json:"style,omitempty"`
	Children []Span    `json:"children,omitempty"`
	MarkDefs []MarkDef `json:"markDefs,omitempty"`

	// List-related fields
	ListItem *string `json:"listItem,omitempty"`
	Level    *int    `json:"level,omitempty"`

	// Raw holds unknown/custom fields and preserves explicit nulls.
	Raw map[string]any `json:"-"`
}

Node represents a Portable Text node (block or custom object). Known fields are modeled; unknown/custom fields are preserved in Raw.

func NewBlock added in v0.1.1

func NewBlock(style string) *Node

NewBlock creates a basic block node.

func NewNode added in v0.1.1

func NewNode(nodeType string) *Node

NewNode creates a custom node with the given type.

func (*Node) AddMarkDef added in v0.1.1

func (n *Node) AddMarkDef(key, markType string, raw map[string]any) *Node

AddMarkDef adds a mark definition to a block node.

func (*Node) AddSpan added in v0.1.1

func (n *Node) AddSpan(text string, marks ...string) *Node

AddSpan adds a text span to a block node.

func (*Node) Clone

func (n *Node) Clone() *Node

Clone deep-copies the node, including Raw and nested slices/maps.

func (*Node) GetListLevel added in v0.1.1

func (n *Node) GetListLevel() int

GetListLevel returns the list level or 1 if not set.

func (*Node) GetStyle added in v0.1.1

func (n *Node) GetStyle() string

GetStyle returns the style or a default value.

func (*Node) GetText added in v0.1.1

func (n *Node) GetText() string

GetText concatenates all span text in a block.

func (*Node) IsBlock

func (n *Node) IsBlock() bool

IsBlock reports whether this node is a Portable Text "block".

func (Node) MarshalJSON

func (n Node) MarshalJSON() ([]byte, error)

type Span

type Span struct {
	Type  string   `json:"_type"`
	Text  *string  `json:"text,omitempty"`
	Marks []string `json:"marks,omitempty"`

	Raw map[string]any `json:"-"`
}

Span represents an inline node in a block's children array. Usually _type == "span", but inline objects are allowed too. For inline objects, Text is typically nil and Raw holds object fields.

func (*Span) HasMark added in v0.1.1

func (s *Span) HasMark(mark string) bool

HasMark checks if a span has a specific mark.

func (Span) MarshalJSON

func (s Span) MarshalJSON() ([]byte, error)

type ValidationError added in v0.1.1

type ValidationError struct {
	Path    string
	Message string
	Node    *Node // Optional reference to problematic node
}

ValidationError represents a validation error with context.

func (*ValidationError) Error added in v0.1.1

func (e *ValidationError) Error() string

type ValidationOptions added in v0.1.1

type ValidationOptions struct {
	RequireKeys      bool // Require _key on all blocks
	CheckMarkDefRefs bool // Verify mark references exist in markDefs
	AllowEmptyText   bool // Allow empty text in spans
}

ValidationOptions controls what Validate checks.

type WalkContext added in v0.1.1

type WalkContext struct {
	Index      int
	Parent     *Node
	Depth      int
	BlockCount int
}

WalkContext provides context during tree traversal.

Directories

Path Synopsis
examples
01-extract-text command
02-find-links command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL