Skip to content

Commit

Permalink
Extension API (#3)
Browse files Browse the repository at this point in the history
- migrate render.CellRenderer implementations to the more flexible Preference API.
This change will allow for a more precise control over which renderers handle which cell/data types.
- organise reusable test doubles in internal/test
- declare public Extension API
  • Loading branch information
bevzzz authored Jan 23, 2024
1 parent aafdae2 commit b2b0928
Show file tree
Hide file tree
Showing 16 changed files with 813 additions and 298 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ c := nb.New(
)

err := c.Convert(&body, b)
if er != nil {
if err != nil {
panic(err)
}

Expand Down
29 changes: 25 additions & 4 deletions convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,22 @@ type Converter interface {
Convert(w io.Writer, source []byte) error
}

// WithExtensions adds extensions.
func WithExtensions(exts ...Extension) Option {
return func(n *Notebook) {
n.extensions = append(n.extensions, exts...)
}
}

// WithRenderer sets a new notebook renderer.
// This option should be supplied before passing any WithRenderOptions.
// Set this option before passing any WithRenderOptions.
func WithRenderer(r render.Renderer) Option {
return func(n *Notebook) {
n.renderer = r
}
}

// WithRendererOptions adds configuration to the notebook renderer.
// WithRendererOptions adds configuration to the current notebook renderer.
func WithRenderOptions(opts ...render.Option) Option {
return func(n *Notebook) {
n.renderer.AddOptions(opts...)
Expand All @@ -37,7 +44,8 @@ func WithRenderOptions(opts ...render.Option) Option {

// Notebook is an extensible Converter implementation.
type Notebook struct {
renderer render.Renderer
renderer render.Renderer
extensions []Extension
}

var _ Converter = (*Notebook)(nil)
Expand All @@ -52,12 +60,15 @@ func New(opts ...Option) *Notebook {
for _, opt := range opts {
opt(&nb)
}
for _, ext := range nb.extensions {
ext.Extend(&nb)
}
return &nb
}

// DefaultRenderer configures an HTML renderer.
func DefaultRenderer() render.Renderer {
return render.New(
return render.NewRenderer(
render.WithCellRenderers(html.NewRenderer()),
)
}
Expand All @@ -71,3 +82,13 @@ func (n *Notebook) Convert(w io.Writer, source []byte) error {
}
return n.renderer.Render(w, nb)
}

// Renderer exposes current renderer, allowing it to be further configured and/or extended.
func (n *Notebook) Renderer() render.Renderer {
return n.renderer
}

// Extension adds new capabilities to the base Notebook.
type Extension interface {
Extend(n *Notebook)
}
78 changes: 77 additions & 1 deletion convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ import (
"testing"

"github.com/bevzzz/nb"
"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/render/html"
"github.com/bevzzz/nb/schema"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
)

// update allows updating golden files via `go test -update`
// update allows updating golden files via `go test -update`.
var update = flag.Bool("update", false, "update .golden files in testdata/")

func TestMain(m *testing.M) {
Expand Down Expand Up @@ -104,3 +107,76 @@ func cmpGolden(tb testing.TB, goldenFile string, got []byte, upd bool) {
tb.Logf("failed to save %s: %v", dotnew, err)
}
}

func TestOptions(t *testing.T) {
t.Run("WithRenderer", func(t *testing.T) {
// Arrange
r := render.NewRenderer()

// Act
n := nb.New(nb.WithRenderer(r))

// Assert
if n.Renderer() != r {
t.Error("option was not applied")
}
})

t.Run("WithRendererOptions", func(t *testing.T) {
// Arrange
var spy spyRenderer

// Act
_ = nb.New(
nb.WithRenderer(&spy),
nb.WithRenderOptions(
render.WithCellRenderers(html.NewRenderer()),
render.WithCellRenderers(html.NewRenderer()),
),
)

// Assert
if l := len(spy.AddedOptions); l != 2 {
t.Errorf("expected %d options applied, got %d", 2, l)
}
})

t.Run("WithExtensions", func(t *testing.T) {
// Arrange
var spy spyRenderer
ext := mockExtension{options: []render.Option{
render.WithCellRenderers(html.NewRenderer()),
}}

// Act
_ = nb.New(
nb.WithRenderer(&spy),
nb.WithExtensions(&ext),
)

// Assert
if len(spy.AddedOptions) == 0 {
t.Errorf("option not applied or applied incorrectly")
}
})
}

// spyRenderer records info about options that were applied to it.
type spyRenderer struct{ AddedOptions []render.Option }

func (r *spyRenderer) Render(io.Writer, schema.Notebook) error { return nil }

func (r *spyRenderer) AddOptions(opts ...render.Option) {
r.AddedOptions = append(r.AddedOptions, opts...)
}

// mockExtension extends Notebook's renderer with options.
type mockExtension struct {
options []render.Option
}

var _ nb.Extension = (*mockExtension)(nil)

func (ext *mockExtension) Extend(n *nb.Notebook) {
n.Renderer().AddOptions(ext.options...)
}
2 changes: 1 addition & 1 deletion decode/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ func TestDecodeBytes(t *testing.T) {
// checkCell compares the cell's type and content to expected.
func checkCell(tb testing.TB, got schema.Cell, want Cell) {
tb.Helper()
require.Equalf(tb, want.Type, got.CellType(), "reported cell type: want %q, got %q", want.Type, got.CellType())
require.Equalf(tb, want.Type, got.Type(), "reported cell type: want %q, got %q", want.Type, got.Type())
require.Equal(tb, want.MimeType, got.MimeType(), "reported mime type")
if got, want := got.Text(), want.Text; !bytes.Equal(want, got) {
tb.Errorf("text:\n(+want) %q\n(-got) %q", want, got)
Expand Down
32 changes: 19 additions & 13 deletions render/html/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/bevzzz/nb/render"
"github.com/bevzzz/nb/schema"
"github.com/bevzzz/nb/schema/common"
)

type Config struct {
Expand All @@ -28,8 +29,7 @@ type Renderer struct {
cfg Config
}

// NewRenderer configures a new HTML renderer.
// By default, it embeds a *Wrapper and will panic if it is set to nil by one of the options.
// NewRenderer configures a new HTML renderer and embeds a *Wrapper to implement render.CellWrapper.
func NewRenderer(opts ...Option) *Renderer {
var cfg Config
for _, opt := range opts {
Expand All @@ -43,18 +43,23 @@ func NewRenderer(opts ...Option) *Renderer {
}
}

func (r *Renderer) RegisterFuncs(reg render.RenderCellFuncRegisterer) {
reg.Register(schema.MarkdownCellType, r.renderMarkdown)
reg.Register(schema.CodeCellType, r.renderCode)
reg.Register(schema.PNG, r.renderImage)
reg.Register(schema.JPEG, r.renderImage)
reg.Register(schema.HTML, r.renderRawHTML)
reg.Register(schema.JSON, r.renderRaw)
reg.Register(schema.StdoutCellType, r.renderRaw)
reg.Register(schema.StderrCellType, r.renderRaw)
reg.Register(schema.PlainTextCellType, r.renderRaw)
func (r *Renderer) RegisterFuncs(reg render.RenderCellFuncRegistry) {
// r.renderMarkdown should provide exact MimeType to override "text/*".
reg.Register(render.Pref{Type: schema.Markdown, MimeType: common.MarkdownText}, r.renderMarkdown)
reg.Register(render.Pref{Type: schema.Code}, r.renderCode)

// Stream (stdout+stderr) and "error" outputs.
reg.Register(render.Pref{Type: schema.Stream}, r.renderRaw)
reg.Register(render.Pref{MimeType: common.Stderr}, r.renderRaw) // renders both "error" output and "stderr" stream

// Various types of raw cell contents and display_data/execute_result outputs.
reg.Register(render.Pref{MimeType: "application/json"}, r.renderRaw)
reg.Register(render.Pref{MimeType: "text/*"}, r.renderRaw)
reg.Register(render.Pref{MimeType: "text/html"}, r.renderRawHTML)
reg.Register(render.Pref{MimeType: "image/*"}, r.renderImage)
}

// renderMarkdown renders markdown cells as pre-formatted text.
func (r *Renderer) renderMarkdown(w io.Writer, cell schema.Cell) error {
io.WriteString(w, "<pre>")
w.Write(cell.Text())
Expand Down Expand Up @@ -93,9 +98,10 @@ func (r *Renderer) renderRawHTML(w io.Writer, cell schema.Cell) error {
return nil
}

// renderImage writes base64-encoded image data.
func (r *Renderer) renderImage(w io.Writer, cell schema.Cell) error {
io.WriteString(w, "<img src=\"data:")
io.WriteString(w, string(cell.Type()))
io.WriteString(w, string(cell.MimeType()))
io.WriteString(w, ";base64, ")
w.Write(cell.Text())
io.WriteString(w, "\" />\n")
Expand Down
Loading

0 comments on commit b2b0928

Please sign in to comment.