From 39582effadae15433a2b20e6496536f7064a633b Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 24 Jan 2024 10:40:18 +0100 Subject: [PATCH 1/8] test: move common test utils to top-level internal/ These helper functions are well-suited for testing extensions, which requires mocking schema.Cell and schema.Notebook. --- {render/internal => internal}/test/cell.go | 0 render/html/html_test.go | 2 +- render/html/wrapper_test.go | 2 +- render/render_test.go | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename {render/internal => internal}/test/cell.go (100%) diff --git a/render/internal/test/cell.go b/internal/test/cell.go similarity index 100% rename from render/internal/test/cell.go rename to internal/test/cell.go diff --git a/render/html/html_test.go b/render/html/html_test.go index 6796ee8..f328f92 100644 --- a/render/html/html_test.go +++ b/render/html/html_test.go @@ -9,9 +9,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" + "github.com/bevzzz/nb/internal/test" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/render/html" - "github.com/bevzzz/nb/render/internal/test" "github.com/bevzzz/nb/schema" ) diff --git a/render/html/wrapper_test.go b/render/html/wrapper_test.go index 0c0cde5..dffa187 100644 --- a/render/html/wrapper_test.go +++ b/render/html/wrapper_test.go @@ -11,8 +11,8 @@ import ( "github.com/stretchr/testify/require" + "github.com/bevzzz/nb/internal/test" "github.com/bevzzz/nb/render/html" - "github.com/bevzzz/nb/render/internal/test" "github.com/bevzzz/nb/schema" "github.com/bevzzz/nb/schema/common" ) diff --git a/render/render_test.go b/render/render_test.go index 8ed9c42..f3a2e85 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "github.com/bevzzz/nb/internal/test" "github.com/bevzzz/nb/render" - "github.com/bevzzz/nb/render/internal/test" "github.com/bevzzz/nb/schema" "github.com/bevzzz/nb/schema/common" "github.com/stretchr/testify/require" From c460391303666790d5bcab6a78e33d20e2edaad7 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 24 Jan 2024 10:42:07 +0100 Subject: [PATCH 2/8] refactor(prefs): hide sorting logic behind exported .Sort --- render/render.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/render/render.go b/render/render.go index d238dab..d6a59e9 100644 --- a/render/render.go +++ b/render/render.go @@ -122,7 +122,7 @@ func (r *renderer) init() { Render: rf, }) } - sort.Sort(r.renderCellFuncs) + r.renderCellFuncs.Sort() }) } @@ -251,6 +251,11 @@ type prefs []pref var _ sort.Interface = (*prefs)(nil) +// Sort preferences from most specific to least specific. +func (s prefs) Sort() { + sort.Sort(s) +} + // Len is the number of pref elements. func (s prefs) Len() int { return len(s) From e7775d372117425a71cfa49916ff7e35c0b4806f Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 24 Jan 2024 10:44:33 +0100 Subject: [PATCH 3/8] refactor(render): apply Options to exported Config struct This allows other packages to pass their own Option implementations without relying on the unexported *renderer. --- render/render.go | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/render/render.go b/render/render.go index d6a59e9..b55a937 100644 --- a/render/render.go +++ b/render/render.go @@ -46,18 +46,23 @@ type RenderCellFuncRegistry interface { // RenderCellFunc writes contents of a specific cell type. type RenderCellFunc func(io.Writer, schema.Cell) error -type Option func(r *renderer) +type Config struct { + CellWrapper + CellRenderers []CellRenderer +} + +type Option func(*Config) // WithCellRenderers adds support for other cell types to the base renderer. // If a renderer implements CellWrapper, it will be used to wrap input and output cells. // Only one cell wrapper can be configured, and so the last implementor will take precedence. func WithCellRenderers(crs ...CellRenderer) Option { - return func(r *renderer) { + return func(cfg *Config) { for _, cr := range crs { - cr.RegisterFuncs(r) + cfg.CellRenderers = append(cfg.CellRenderers, cr) if cw, ok := cr.(CellWrapper); ok { - r.cellWrapper = cw + cfg.CellWrapper = cw } } } @@ -78,9 +83,10 @@ type CellWrapper interface { // renderer is a base Renderer implementation. // It does not support any cell types out of the box and should be extended by the client using the available Options. type renderer struct { - once sync.Once - cellWrapper CellWrapper + once sync.Once + config Config + cellWrapper CellWrapper renderCellFuncsTmp map[Pref]RenderCellFunc // renderCellFuncsTmp holds intermediary preference entries. renderCellFuncs prefs // renderCellFuncs is sorted and will only be modified once. } @@ -90,7 +96,6 @@ var _ RenderCellFuncRegistry = (*renderer)(nil) // NewRenderer extends the base renderer with the passed options. func NewRenderer(opts ...Option) Renderer { r := renderer{ - cellWrapper: nil, renderCellFuncsTmp: make(map[Pref]RenderCellFunc), } r.AddOptions(opts...) @@ -102,7 +107,7 @@ var _ RenderCellFuncRegistry = (*renderer)(nil) func (r *renderer) AddOptions(opts ...Option) { for _, opt := range opts { - opt(r) + opt(&r.config) } } @@ -116,6 +121,10 @@ func (r *renderer) Register(pref Pref, f RenderCellFunc) { func (r *renderer) init() { r.once.Do(func() { + r.cellWrapper = r.config.CellWrapper + for _, cr := range r.config.CellRenderers { + cr.RegisterFuncs(r) + } for p, rf := range r.renderCellFuncsTmp { r.renderCellFuncs = append(r.renderCellFuncs, pref{ Pref: p, From bd87b45aacc7d3ebdd5bfe98323e618404e1722e Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 24 Jan 2024 10:48:20 +0100 Subject: [PATCH 4/8] feat: provide adapters for goldmark and blackfriday These are the 2 most popular Markdown renderers, which can no be passed as extensions and used to render markdown cells (without actually pulling them in as package deps) --- extension/extension_test.go | 58 +++++++++++++++++++++++++++++++++++ extension/markdown.go | 48 +++++++++++++++++++++++++++++ extension/markdown/md.go | 49 +++++++++++++++++++++++++++++ extension/markdown/md_test.go | 43 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 extension/extension_test.go create mode 100644 extension/markdown.go create mode 100644 extension/markdown/md.go create mode 100644 extension/markdown/md_test.go diff --git a/extension/extension_test.go b/extension/extension_test.go new file mode 100644 index 0000000..e7beeb0 --- /dev/null +++ b/extension/extension_test.go @@ -0,0 +1,58 @@ +package extension_test + +import ( + "bytes" + "io" + "testing" + + "github.com/bevzzz/nb" + "github.com/bevzzz/nb/extension" + "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" + "github.com/stretchr/testify/require" +) + +func TestMarkdown(t *testing.T) { + // Arrange + var buf bytes.Buffer + want := []byte("Hi, mom!") + c := nb.New(nb.WithExtensions( + extension.NewMarkdown(func(w io.Writer, c schema.Cell) error { + w.Write(want) + return nil + }), + )) + + // Override default CellWrapper to compare bare cell contents only. + r := c.Renderer() + r.AddOptions(render.WithCellRenderers(&fakeWrapper{})) + + // Act + err := r.Render(&buf, test.Notebook(test.Markdown("Bye!"))) + require.NoError(t, err) + + // Assert + if got := buf.Bytes(); !bytes.Equal(want, got) { + t.Errorf("wrong content: want %q, got %q", want, got) + } +} + +// fakeWrapper calls the passed RenderCellFunc immediately without any additional writes to w. +type fakeWrapper struct{} + +var _ render.CellWrapper = (*fakeWrapper)(nil) + +func (*fakeWrapper) RegisterFuncs(render.RenderCellFuncRegistry) {} +func (*fakeWrapper) Wrap(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { return r(w, c) } +func (*fakeWrapper) WrapInput(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { + return r(w, c) +} +func (*fakeWrapper) WrapOutput(w io.Writer, out schema.Outputter, r render.RenderCellFunc) error { + for _, c := range out.Outputs() { + if err := r(w, c); err != nil { + return err + } + } + return nil +} diff --git a/extension/markdown.go b/extension/markdown.go new file mode 100644 index 0000000..3c45d6b --- /dev/null +++ b/extension/markdown.go @@ -0,0 +1,48 @@ +package extension + +import ( + "github.com/bevzzz/nb" + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" + "github.com/bevzzz/nb/schema/common" +) + +// NewMarkdown overrides the default rendering function for markdown cells. +// +// While its lax signature allows passing any arbitrary RenderCellFunc, +// it will be best used to extend nb with existing markdown converters. +// Package extension/markdown offers elegant wrappers for some of the popular options: +// +// extension.NewMarkdown( +// markdown.Blackfriday(blackfriday.MarkdownCommon) +// ) +// +// or +// +// extension.NewMarkdown( +// markdown.Goldmark(func(b []byte, w io.Writer) error { +// return goldmark.Convert(b, w) +// }) +// ) +func NewMarkdown(f render.RenderCellFunc) nb.Extension { + return &markdown{ + render: f, + } +} + +type markdown struct { + render render.RenderCellFunc +} + +var _ nb.Extension = (*markdown)(nil) +var _ render.CellRenderer = (*markdown)(nil) + +// RegisterFuncs registers a new RenderCellFunc for markdown cells. +func (md *markdown) RegisterFuncs(reg render.RenderCellFuncRegistry) { + reg.Register(render.Pref{Type: schema.Markdown, MimeType: common.MarkdownText}, md.render) +} + +// Extend adds markdown as a cell renderer. +func (md *markdown) Extend(n *nb.Notebook) { + n.Renderer().AddOptions(render.WithCellRenderers(md)) +} diff --git a/extension/markdown/md.go b/extension/markdown/md.go new file mode 100644 index 0000000..42071c7 --- /dev/null +++ b/extension/markdown/md.go @@ -0,0 +1,49 @@ +// Package markdown provides convenient adapters for some popular packages +// for rendering Markdown, making it simple to use those as nb extensions. +package markdown + +import ( + "io" + + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" +) + +// Blackfriday wraps [blackfriday]-style function in RenderCellFunc. +// +// Usage: +// +// extension.NewMarkdown( +// markdown.Blackfriday(blackfriday.MarkdownCommon) +// ) +// +// [blackfriday]: https://github.com/russross/blackfriday +func Blackfriday(convert func([]byte) []byte) render.RenderCellFunc { + return func(w io.Writer, cell schema.Cell) error { + if _, err := w.Write(convert(cell.Text())); err != nil { + return err + } + return nil + } +} + +// Goldmark wraps [goldmark]-style function in RenderCellFunc. +// +// Usage: +// +// extension.NewMarkdown( +// markdown.Goldmark(func(b []byte, w io.Writer) error { +// return goldmark.Convert(b, w, parseOptions...) +// }) +// ) +// +// Notice, how Goldmark is a bit more verbose compared to Blackfriday: +// this is because goldmark.Convert accepts variadic parser.ParseOptions, which +// is a dependency the client should capture in the closure and pass manually. +// +// [goldmark]: https://github.com/yuin/goldmark +func Goldmark(write func([]byte, io.Writer) error) render.RenderCellFunc { + return func(w io.Writer, cell schema.Cell) error { + return write(cell.Text(), w) + } +} diff --git a/extension/markdown/md_test.go b/extension/markdown/md_test.go new file mode 100644 index 0000000..c4a7cbc --- /dev/null +++ b/extension/markdown/md_test.go @@ -0,0 +1,43 @@ +package markdown_test + +import ( + "io" + "strings" + "testing" + + "github.com/bevzzz/nb/extension/markdown" + "github.com/bevzzz/nb/internal/test" +) + +func TestBlackfriday(t *testing.T) { + // Arrange + var sb strings.Builder + want := "Hi, mom!" + render := markdown.Blackfriday(func(b []byte) []byte { return b }) + + // Act + render(&sb, test.Markdown("Hi, mom!")) + + // Assert + if got := sb.String(); got != want { + t.Errorf("wrong content: want %q, got %q", want, got) + } +} + +func TestGoldmark(t *testing.T) { + // Arrange + var sb strings.Builder + want := "Hi, mom!" + render := markdown.Goldmark(func(b []byte, w io.Writer) error { + w.Write(b) + return nil + }) + + // Act + render(&sb, test.Markdown("Hi, mom!")) + + // Assert + if got := sb.String(); got != want { + t.Errorf("wrong content: want %q, got %q", want, got) + } +} From 782a14dbf4e828971f87951fac6dcdf78be48534 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Wed, 24 Jan 2024 12:25:10 +0100 Subject: [PATCH 5/8] feat(ext): add adapter for ansihtml.ConvertToHTML Collected all adapters in a single extension/adapter package, because there is a lot of shared logic and structure and no naming clashes. --- extension/adapter/adapter_test.go | 62 +++++++++++++++++++++++++++ extension/adapter/ansi.go | 32 ++++++++++++++ extension/adapter/doc.go | 10 +++++ extension/{markdown => adapter}/md.go | 16 +++---- extension/extension_test.go | 57 +++++++++++++++++++++--- extension/markdown.go | 8 ++-- extension/markdown/md_test.go | 43 ------------------- extension/stream.go | 44 +++++++++++++++++++ 8 files changed, 209 insertions(+), 63 deletions(-) create mode 100644 extension/adapter/adapter_test.go create mode 100644 extension/adapter/ansi.go create mode 100644 extension/adapter/doc.go rename extension/{markdown => adapter}/md.go (69%) delete mode 100644 extension/markdown/md_test.go create mode 100644 extension/stream.go diff --git a/extension/adapter/adapter_test.go b/extension/adapter/adapter_test.go new file mode 100644 index 0000000..e3ddac1 --- /dev/null +++ b/extension/adapter/adapter_test.go @@ -0,0 +1,62 @@ +package adapter_test + +import ( + "io" + "strings" + "testing" + + "github.com/bevzzz/nb/extension/adapter" + "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" +) + +func TestAdapter(t *testing.T) { + for _, tt := range []struct { + name string + render render.RenderCellFunc + cell schema.Cell + want string + }{ + { + name: "Goldmark", + render: adapter.Goldmark(func(b []byte, w io.Writer) error { + w.Write(b) + return nil + }), + cell: test.Markdown("Hi, mom!"), + want: "Hi, mom!", + }, + { + name: "Blackfriday", + render: adapter.Blackfriday(func(b []byte) []byte { return b }), + cell: test.Markdown("Hi, mom!"), + want: "Hi, mom!", + }, + { + name: "AnsiHtml", + render: adapter.AnsiHtml(func(b []byte) []byte { return b }), + cell: test.Stdout("Hi, mom!"), + want: "Hi, mom!", + }, + { + name: "AnsiHtml", + render: adapter.AnsiHtml(func(b []byte) []byte { return b }), + cell: test.Stderr("Hi, mom!"), + want: "Hi, mom!", + }, + } { + t.Run(tt.name, func(t *testing.T) { + // Arrange + var sb strings.Builder + + // Act + tt.render(&sb, tt.cell) + + // Assert + if got := sb.String(); got != tt.want { + t.Errorf("wrong content: want %q, got %q", tt.want, got) + } + }) + } +} diff --git a/extension/adapter/ansi.go b/extension/adapter/ansi.go new file mode 100644 index 0000000..4971587 --- /dev/null +++ b/extension/adapter/ansi.go @@ -0,0 +1,32 @@ +package adapter + +import ( + "io" + + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" +) + +// AnsiHtml wraps [ansihtml]-style function in RenderCellFunc. +// +// Usage: +// +// extension.NewStream( +// adapter.AnsiHtml(ansihtml.ConvertToHTML) +// ) +// +// To force ansihtml to use classes instead of inline styles, pass an anonymous function intead: +// +// extension.NewStream( +// adapter.AnsiHtml(func([]byte) []byte) { +// ansihtml.ConvertToHTMLWithClasses(b, "class-", false) +// }) +// ) +// +// [ansihtml]: https://github.com/robert-nix/ansihtml +func AnsiHtml(convert func([]byte) []byte) render.RenderCellFunc { + return func(w io.Writer, cell schema.Cell) (err error) { + _, err = w.Write(convert(cell.Text())) + return + } +} diff --git a/extension/adapter/doc.go b/extension/adapter/doc.go new file mode 100644 index 0000000..69aa2de --- /dev/null +++ b/extension/adapter/doc.go @@ -0,0 +1,10 @@ +// Package adapter provides convenient adapters for other popular packages +// making it simple to use those as nb extensions. +// +// - Markdown: [goldmark] and [blackfriday] +// - ANSI to HTML conversion: [ansihtml] +// +// [goldmark]: https://github.com/yuin/goldmark +// [blackfriday]: https://github.com/russross/blackfriday +// [ansihtml]: https://github.com/robert-nix/ansihtml +package adapter diff --git a/extension/markdown/md.go b/extension/adapter/md.go similarity index 69% rename from extension/markdown/md.go rename to extension/adapter/md.go index 42071c7..ce11c3f 100644 --- a/extension/markdown/md.go +++ b/extension/adapter/md.go @@ -1,6 +1,4 @@ -// Package markdown provides convenient adapters for some popular packages -// for rendering Markdown, making it simple to use those as nb extensions. -package markdown +package adapter import ( "io" @@ -14,16 +12,14 @@ import ( // Usage: // // extension.NewMarkdown( -// markdown.Blackfriday(blackfriday.MarkdownCommon) +// adapter.Blackfriday(blackfriday.MarkdownCommon) // ) // // [blackfriday]: https://github.com/russross/blackfriday func Blackfriday(convert func([]byte) []byte) render.RenderCellFunc { - return func(w io.Writer, cell schema.Cell) error { - if _, err := w.Write(convert(cell.Text())); err != nil { - return err - } - return nil + return func(w io.Writer, cell schema.Cell) (err error) { + _, err = w.Write(convert(cell.Text())) + return } } @@ -32,7 +28,7 @@ func Blackfriday(convert func([]byte) []byte) render.RenderCellFunc { // Usage: // // extension.NewMarkdown( -// markdown.Goldmark(func(b []byte, w io.Writer) error { +// adapter.Goldmark(func(b []byte, w io.Writer) error { // return goldmark.Convert(b, w, parseOptions...) // }) // ) diff --git a/extension/extension_test.go b/extension/extension_test.go index e7beeb0..c156ee3 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -1,8 +1,8 @@ package extension_test import ( - "bytes" "io" + "strings" "testing" "github.com/bevzzz/nb" @@ -15,11 +15,11 @@ import ( func TestMarkdown(t *testing.T) { // Arrange - var buf bytes.Buffer - want := []byte("Hi, mom!") + var sb strings.Builder + want := "Hi, mom!" c := nb.New(nb.WithExtensions( extension.NewMarkdown(func(w io.Writer, c schema.Cell) error { - w.Write(want) + io.WriteString(w, want) return nil }), )) @@ -29,15 +29,60 @@ func TestMarkdown(t *testing.T) { r.AddOptions(render.WithCellRenderers(&fakeWrapper{})) // Act - err := r.Render(&buf, test.Notebook(test.Markdown("Bye!"))) + err := r.Render(&sb, test.Notebook(test.Markdown("Bye!"))) require.NoError(t, err) // Assert - if got := buf.Bytes(); !bytes.Equal(want, got) { + if got := sb.String(); got != want { t.Errorf("wrong content: want %q, got %q", want, got) } } +func TestStream(t *testing.T) { + for _, tt := range []struct { + name string + cell schema.Cell + }{ + { + name: "handles stream to stdout", + cell: test.Stdout("Hi, mom!"), + }, + { + name: "handles stream to stderr", + cell: test.Stderr("Hi, mom!"), + }, + { + name: "handles error output", + cell: test.ErrorOutput("Hi, mom!"), + }, + } { + t.Run(tt.name, func(t *testing.T) { + // Arrange + var sb strings.Builder + want := "Hi, mom!" + c := nb.New(nb.WithExtensions( + extension.NewStream(func(w io.Writer, c schema.Cell) error { + io.WriteString(w, want) + return nil + }), + )) + + // Override default CellWrapper to compare bare cell contents only. + r := c.Renderer() + r.AddOptions(render.WithCellRenderers(&fakeWrapper{})) + + // Act + err := r.Render(&sb, test.Notebook(tt.cell)) + require.NoError(t, err) + + // Assert + if got := sb.String(); got != want { + t.Errorf("wrong content: want %q, got %q", want, got) + } + }) + } +} + // fakeWrapper calls the passed RenderCellFunc immediately without any additional writes to w. type fakeWrapper struct{} diff --git a/extension/markdown.go b/extension/markdown.go index 3c45d6b..a1b5799 100644 --- a/extension/markdown.go +++ b/extension/markdown.go @@ -8,19 +8,19 @@ import ( ) // NewMarkdown overrides the default rendering function for markdown cells. -// +// // While its lax signature allows passing any arbitrary RenderCellFunc, // it will be best used to extend nb with existing markdown converters. -// Package extension/markdown offers elegant wrappers for some of the popular options: +// Package extension/adapters offers elegant wrappers for some of the popular options: // // extension.NewMarkdown( -// markdown.Blackfriday(blackfriday.MarkdownCommon) +// adapter.Blackfriday(blackfriday.MarkdownCommon) // ) // // or // // extension.NewMarkdown( -// markdown.Goldmark(func(b []byte, w io.Writer) error { +// adapter.Goldmark(func(b []byte, w io.Writer) error { // return goldmark.Convert(b, w) // }) // ) diff --git a/extension/markdown/md_test.go b/extension/markdown/md_test.go deleted file mode 100644 index c4a7cbc..0000000 --- a/extension/markdown/md_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package markdown_test - -import ( - "io" - "strings" - "testing" - - "github.com/bevzzz/nb/extension/markdown" - "github.com/bevzzz/nb/internal/test" -) - -func TestBlackfriday(t *testing.T) { - // Arrange - var sb strings.Builder - want := "Hi, mom!" - render := markdown.Blackfriday(func(b []byte) []byte { return b }) - - // Act - render(&sb, test.Markdown("Hi, mom!")) - - // Assert - if got := sb.String(); got != want { - t.Errorf("wrong content: want %q, got %q", want, got) - } -} - -func TestGoldmark(t *testing.T) { - // Arrange - var sb strings.Builder - want := "Hi, mom!" - render := markdown.Goldmark(func(b []byte, w io.Writer) error { - w.Write(b) - return nil - }) - - // Act - render(&sb, test.Markdown("Hi, mom!")) - - // Assert - if got := sb.String(); got != want { - t.Errorf("wrong content: want %q, got %q", want, got) - } -} diff --git a/extension/stream.go b/extension/stream.go new file mode 100644 index 0000000..ec742cb --- /dev/null +++ b/extension/stream.go @@ -0,0 +1,44 @@ +package extension + +import ( + "github.com/bevzzz/nb" + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" + "github.com/bevzzz/nb/schema/common" +) + +// NewStream overrides the default rendering function for "stream" and "error" output cells. +// These will often be formatted with ANSI-color codes, which you may want to replace with +// styled HTML tags or strip from the output completely. +// +// For example, use [ansihtml] with a dedicated adapter: +// +// extension.NewStream( +// adapter.AnsiHtml(ansihtml.ConvertToHTML) +// ) +// +// [ansihtml]: https://github.com/robert-nix/ansihtml +func NewStream(f render.RenderCellFunc) nb.Extension { + return &stream{ + render: f, + } +} + +type stream struct { + render render.RenderCellFunc +} + +var _ nb.Extension = (*stream)(nil) +var _ render.CellRenderer = (*stream)(nil) + +// RegisterFuncs registers a new RenderCellFunc for stream output cells. +func (s *stream) RegisterFuncs(reg render.RenderCellFuncRegistry) { + reg.Register(render.Pref{Type: schema.Stream, MimeType: common.Stdout}, s.render) + reg.Register(render.Pref{Type: schema.Stream, MimeType: common.Stderr}, s.render) + reg.Register(render.Pref{Type: schema.Error, MimeType: common.Stderr}, s.render) +} + +// Extend adds stream as a cell renderer. +func (s *stream) Extend(n *nb.Notebook) { + n.Renderer().AddOptions(render.WithCellRenderers(s)) +} From 3d5fc77413b93e87faf4f15bab7e5336308a196b Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Thu, 25 Jan 2024 10:38:26 +0100 Subject: [PATCH 6/8] feat: decode cell attachments in raw and markdown cells The testing package got the corresponding fixture and is now made public (internal/test -> pkg/test) so that it can be used in external packages providing nb extensions. --- decode/decode_test.go | 98 ++++++++++++++++++++++++++----- extension/adapter/adapter_test.go | 2 +- extension/extension_test.go | 2 +- {internal => pkg}/test/cell.go | 80 +++++++++++++++++++++++++ render/html/html_test.go | 2 +- render/html/wrapper_test.go | 2 +- render/render_test.go | 2 +- schema/common/notebook.go | 2 +- schema/schema.go | 13 ++++ schema/v4/schema.go | 25 ++++++++ 10 files changed, 208 insertions(+), 20 deletions(-) rename {internal => pkg}/test/cell.go (59%) diff --git a/decode/decode_test.go b/decode/decode_test.go index 6d5280a..d597988 100644 --- a/decode/decode_test.go +++ b/decode/decode_test.go @@ -19,6 +19,17 @@ type Cell struct { Text []byte } +type WithAttachments struct { + Cell + Filename string + MimeType string + Data []byte +} + +func (w WithAttachments) HasAttachments() bool { + return w.Filename != "" +} + func TestDecodeBytes(t *testing.T) { t.Run("notebook", func(t *testing.T) { for _, tt := range []struct { @@ -54,19 +65,30 @@ func TestDecodeBytes(t *testing.T) { for _, tt := range []struct { name string json string - want Cell + want WithAttachments }{ { name: "v4.4", json: `{ "nbformat": 4, "nbformat_minor": 4, "metadata": {}, "cells": [ - {"cell_type": "markdown", "metadata": {}, "source": ["Join", " ", "me"]} + {"cell_type": "markdown", "metadata": {}, "source": [ + "Look", " at ", "me: ![alt](attachment:photo.png)" + ], "attachments": { + "photo.png": { + "image/png": "base64-encoded-image-data" + } + }} ] }`, - want: Cell{ - Type: schema.Markdown, - MimeType: common.MarkdownText, - Text: []byte("Join me"), + want: WithAttachments{ + Cell: Cell{ + Type: schema.Markdown, + MimeType: common.MarkdownText, + Text: []byte("Look at me: ![alt](attachment:photo.png)"), + }, + Filename: "photo.png", + MimeType: "image/png", + Data: []byte("base64-encoded-image-data"), }, }, } { @@ -77,7 +99,7 @@ func TestDecodeBytes(t *testing.T) { got := nb.Cells() require.Len(t, got, 1, "expected 1 cell") - checkCell(t, got[0], tt.want) + checkCellWithAttachments(t, got[0], tt.want) }) } }) @@ -86,7 +108,7 @@ func TestDecodeBytes(t *testing.T) { for _, tt := range []struct { name string json string - want Cell + want WithAttachments }{ { name: "v4.4: no explicit mime-type", @@ -95,11 +117,11 @@ func TestDecodeBytes(t *testing.T) { {"cell_type": "raw", "source": ["Plain as the nose on your face"]} ] }`, - want: Cell{ + want: WithAttachments{Cell: Cell{ Type: schema.Raw, MimeType: common.PlainText, Text: []byte("Plain as the nose on your face"), - }, + }}, }, { name: "v4.4: metadata.format has specific mime-type", @@ -108,11 +130,11 @@ func TestDecodeBytes(t *testing.T) { {"cell_type": "raw", "metadata": {"format": "text/html"}, "source": ["

Hi, mom!

"]} ] }`, - want: Cell{ + want: WithAttachments{Cell: Cell{ Type: schema.Raw, MimeType: "text/html", Text: []byte("

Hi, mom!

"), - }, + }}, }, { name: "v4.4: metadata.raw_mimetype has specific mime-type", @@ -121,10 +143,35 @@ func TestDecodeBytes(t *testing.T) { {"cell_type": "raw", "metadata": {"raw_mimetype": "application/x-latex"}, "source": ["$$"]} ] }`, - want: Cell{ + want: WithAttachments{Cell: Cell{ Type: schema.Raw, MimeType: "application/x-latex", Text: []byte("$$"), + }}, + }, + { + name: "v4.4: with attachments", + json: `{ + "nbformat": 4, "nbformat_minor": 4, "metadata": {}, "cells": [ + { + "cell_type": "raw", "metadata": {}, + "source": ["![alt](attachment:photo.png)"], "attachments": { + "photo.png": { + "image/png": "base64-encoded-image-data" + } + } + } + ] + }`, + want: WithAttachments{ + Cell: Cell{ + Type: schema.Raw, + MimeType: common.PlainText, + Text: []byte("![alt](attachment:photo.png)"), + }, + Filename: "photo.png", + MimeType: "image/png", + Data: []byte("base64-encoded-image-data"), }, }, } { @@ -135,7 +182,7 @@ func TestDecodeBytes(t *testing.T) { got := nb.Cells() require.Len(t, got, 1, "expected 1 cell") - checkCell(t, got[0], tt.want) + checkCellWithAttachments(t, got[0], tt.want) }) } }) @@ -398,6 +445,29 @@ func checkCell(tb testing.TB, got schema.Cell, want Cell) { } } +// checkCellWithAttachments compares the cell's type, content, and attachments to expected. +func checkCellWithAttachments(tb testing.TB, got schema.Cell, want WithAttachments) { + tb.Helper() + checkCell(tb, got, want.Cell) + if !want.HasAttachments() { + return + } + + cell, ok := got.(schema.HasAttachments) + if !ok { + tb.Fatal("cell has no attachments (does not implement schema.HasAttachments)") + } + + var mb schema.MimeBundle + att := cell.Attachments() + if mb = att.MimeBundle(want.Filename); mb == nil { + tb.Fatalf("no data for %s, want %q", want.Filename, want.Data) + } + + require.Equal(tb, want.MimeType, mb.MimeType(), "reported mime-type") + require.Equal(tb, want.Data, mb.Text(), "attachment data") +} + // toCodeCell fails the test if the cell does not implement schema.CodeCell. func toCodeCell(tb testing.TB, cell schema.Cell) schema.CodeCell { tb.Helper() diff --git a/extension/adapter/adapter_test.go b/extension/adapter/adapter_test.go index e3ddac1..e1bd30e 100644 --- a/extension/adapter/adapter_test.go +++ b/extension/adapter/adapter_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/bevzzz/nb/extension/adapter" - "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/pkg/test" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/schema" ) diff --git a/extension/extension_test.go b/extension/extension_test.go index c156ee3..8a19957 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -7,7 +7,7 @@ import ( "github.com/bevzzz/nb" "github.com/bevzzz/nb/extension" - "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/pkg/test" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/schema" "github.com/stretchr/testify/require" diff --git a/internal/test/cell.go b/pkg/test/cell.go similarity index 59% rename from internal/test/cell.go rename to pkg/test/cell.go index 250832f..96c6379 100644 --- a/internal/test/cell.go +++ b/pkg/test/cell.go @@ -1,3 +1,7 @@ +// Package test provides test doubles that implement some of nb interfaces. +// Authors of nb-extension packages are encouraged to use them as they make +// for a uniform test code across different packages. +// See it's example usages in schema/**/*_test.go files. package test import ( @@ -95,3 +99,79 @@ var _ schema.Notebook = (*cells)(nil) func (n cells) Version() (v schema.Version) { return } func (n cells) Cells() []schema.Cell { return n } + +// WithAttachments creates a cell that has an attachment. +// +// The underlying test implementation for schema.MimeBundle accesses +// its keys in a random order and should always be created with 1 element only +// to keep test outcomes stable and predictable. +// +// Example: +// +// test.WithAttachments( +// test.Markdown("![img](attachment:photo:png)"), +// "photo.png", +// map[string]interface{"image/png": "base64-encoded-image"} +// ) +func WithAttachment(c schema.Cell, filename string, mimebundle map[string]interface{}) interface { + schema.Cell + schema.HasAttachments +} { + return &struct { + schema.Cell + schema.HasAttachments + }{ + Cell: c, + HasAttachments: &cellAttachment{ + filename: filename, + mb: mimebundle, + }, + } +} + +// cellWithAttachment fakes a single cell attachment. +type cellAttachment struct { + filename string + mb mimebundle +} + +var _ schema.HasAttachments = (*cellAttachment)(nil) +var _ schema.Attachments = (*cellAttachment)(nil) + +func (c *cellAttachment) Attachments() schema.Attachments { + return c +} + +// MimeBundle returns the underlying mime-bundle if the filename matches. +func (c *cellAttachment) MimeBundle(filename string) schema.MimeBundle { + if filename != c.filename { + return nil + } + return c.mb +} + +// mimebundle is a mock implementation of schema.MimeBundle, which always +// returns the mime-type and content of its first (random access) element. +// It does not differentiate between "richer" mime-types and should not be +// created with more than one entry to keep the tests stable and reproducible. +type mimebundle map[string]interface{} + +var _ schema.MimeBundle = new(mimebundle) + +func (mb mimebundle) MimeType() string { + for mt := range mb { + return mt + } + return common.PlainText +} + +func (mb mimebundle) Text() []byte { + return mb[mb.MimeType()].([]byte) +} + +func (mb mimebundle) PlainText() []byte { + if mb.MimeType() == common.PlainText { + return mb.Text() + } + return nil +} diff --git a/render/html/html_test.go b/render/html/html_test.go index f328f92..86ed678 100644 --- a/render/html/html_test.go +++ b/render/html/html_test.go @@ -9,7 +9,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" - "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/pkg/test" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/render/html" "github.com/bevzzz/nb/schema" diff --git a/render/html/wrapper_test.go b/render/html/wrapper_test.go index dffa187..db95d7d 100644 --- a/render/html/wrapper_test.go +++ b/render/html/wrapper_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/pkg/test" "github.com/bevzzz/nb/render/html" "github.com/bevzzz/nb/schema" "github.com/bevzzz/nb/schema/common" diff --git a/render/render_test.go b/render/render_test.go index f3a2e85..a559a50 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/bevzzz/nb/internal/test" + "github.com/bevzzz/nb/pkg/test" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/schema" "github.com/bevzzz/nb/schema/common" diff --git a/schema/common/notebook.go b/schema/common/notebook.go index 3550967..8246fe7 100644 --- a/schema/common/notebook.go +++ b/schema/common/notebook.go @@ -9,7 +9,7 @@ import ( type Notebook struct { VersionMajor int `json:"nbformat"` VersionMinor int `json:"nbformat_minor"` - Metadata json.RawMessage `json:"metadata"` + Metadata json.RawMessage `json:"metadata"` // TODO: omitempty Cells []json.RawMessage `json:"cells"` } diff --git a/schema/schema.go b/schema/schema.go index 15a38e9..e6df60d 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -34,6 +34,12 @@ type Cell interface { Text() []byte } +type HasAttachments interface { + // Attachments are only defined for v4.0 and above for markdown and raw cells + // and may be omitted in the JSON. Cells without attachments should return nil. + Attachments() Attachments +} + // CellType reports the intended cell type to the components that work // with notebook cells through the Cell interface. // @@ -123,3 +129,10 @@ type MimeBundle interface { // A renderer may want to fallback to this option if it is not able to render the richer mime-type. PlainText() []byte } + +// Attachments are data for inline images stored as a mime-bundle keyed by filename. +type Attachments interface { + // MimeBundle returns a mime-bundle associated with the filename. + // If no data is present for the file, implementations should return nil. + MimeBundle(filename string) MimeBundle +} diff --git a/schema/v4/schema.go b/schema/v4/schema.go index de1885b..940f122 100644 --- a/schema/v4/schema.go +++ b/schema/v4/schema.go @@ -60,10 +60,12 @@ func (nm *NotebookMetadata) Language() string { // Markdown defines the schema for a "markdown" cell. type Markdown struct { + Att Attachments `json:"attachments,omitempty"` Source common.MultilineString `json:"source"` } var _ schema.Cell = (*Markdown)(nil) +var _ schema.HasAttachments = (*Markdown)(nil) func (md *Markdown) Type() schema.CellType { return schema.Markdown @@ -77,13 +79,19 @@ func (md *Markdown) Text() []byte { return md.Source.Text() } +func (md *Markdown) Attachments() schema.Attachments { + return md.Att +} + // Raw defines the schema for a "raw" cell. type Raw struct { + Att Attachments `json:"attachments,omitempty"` Source common.MultilineString `json:"source"` Metadata RawCellMetadata `json:"metadata"` } var _ schema.Cell = (*Raw)(nil) +var _ schema.HasAttachments = (*Raw)(nil) func (raw *Raw) Type() schema.CellType { return schema.Raw @@ -97,6 +105,23 @@ func (raw *Raw) Text() []byte { return raw.Source.Text() } +func (raw *Raw) Attachments() schema.Attachments { + return raw.Att +} + +// Attachments store mime-bundles keyed by filename. +type Attachments map[string]MimeBundle + +var _ schema.Attachments = new(Attachments) + +func (att Attachments) MimeBundle(filename string) schema.MimeBundle { + mb, ok := att[filename] + if !ok { + return nil + } + return mb +} + // RawCellMetadata may specify a target conversion format. type RawCellMetadata struct { Format *string `json:"format"` From d3f8a53f44cc20af344394364c83817ff0735a3f Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Thu, 25 Jan 2024 13:51:13 +0100 Subject: [PATCH 7/8] feat(pkg/test): export NoWrapper render option --- extension/extension_test.go | 56 ++++++++++++------------------------- pkg/test/render.go | 31 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 pkg/test/render.go diff --git a/extension/extension_test.go b/extension/extension_test.go index 8a19957..2e80c50 100644 --- a/extension/extension_test.go +++ b/extension/extension_test.go @@ -8,7 +8,6 @@ import ( "github.com/bevzzz/nb" "github.com/bevzzz/nb/extension" "github.com/bevzzz/nb/pkg/test" - "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/schema" "github.com/stretchr/testify/require" ) @@ -17,16 +16,16 @@ func TestMarkdown(t *testing.T) { // Arrange var sb strings.Builder want := "Hi, mom!" - c := nb.New(nb.WithExtensions( - extension.NewMarkdown(func(w io.Writer, c schema.Cell) error { - io.WriteString(w, want) - return nil - }), - )) - - // Override default CellWrapper to compare bare cell contents only. + c := nb.New( + nb.WithExtensions( + extension.NewMarkdown(func(w io.Writer, c schema.Cell) error { + io.WriteString(w, want) + return nil + }), + ), + nb.WithRenderOptions(test.NoWrapper), + ) r := c.Renderer() - r.AddOptions(render.WithCellRenderers(&fakeWrapper{})) // Act err := r.Render(&sb, test.Notebook(test.Markdown("Bye!"))) @@ -60,16 +59,16 @@ func TestStream(t *testing.T) { // Arrange var sb strings.Builder want := "Hi, mom!" - c := nb.New(nb.WithExtensions( - extension.NewStream(func(w io.Writer, c schema.Cell) error { - io.WriteString(w, want) - return nil - }), - )) - - // Override default CellWrapper to compare bare cell contents only. + c := nb.New( + nb.WithExtensions( + extension.NewStream(func(w io.Writer, c schema.Cell) error { + io.WriteString(w, want) + return nil + }), + ), + nb.WithRenderOptions(test.NoWrapper), + ) r := c.Renderer() - r.AddOptions(render.WithCellRenderers(&fakeWrapper{})) // Act err := r.Render(&sb, test.Notebook(tt.cell)) @@ -82,22 +81,3 @@ func TestStream(t *testing.T) { }) } } - -// fakeWrapper calls the passed RenderCellFunc immediately without any additional writes to w. -type fakeWrapper struct{} - -var _ render.CellWrapper = (*fakeWrapper)(nil) - -func (*fakeWrapper) RegisterFuncs(render.RenderCellFuncRegistry) {} -func (*fakeWrapper) Wrap(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { return r(w, c) } -func (*fakeWrapper) WrapInput(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { - return r(w, c) -} -func (*fakeWrapper) WrapOutput(w io.Writer, out schema.Outputter, r render.RenderCellFunc) error { - for _, c := range out.Outputs() { - if err := r(w, c); err != nil { - return err - } - } - return nil -} diff --git a/pkg/test/render.go b/pkg/test/render.go new file mode 100644 index 0000000..78b65ba --- /dev/null +++ b/pkg/test/render.go @@ -0,0 +1,31 @@ +package test + +import ( + "io" + + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" +) + +// NoWrapper overrides the default cell wrapper so that the cell content could be compared +// directly without parsing the surrounding wrap. Useful for testing extensions. +var NoWrapper = render.WithCellRenderers(&fakeWrapper{}) + +// fakeWrapper calls the passed RenderCellFunc immediately without any additional writes to w. +type fakeWrapper struct{} + +var _ render.CellWrapper = (*fakeWrapper)(nil) + +func (*fakeWrapper) RegisterFuncs(render.RenderCellFuncRegistry) {} +func (*fakeWrapper) Wrap(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { return r(w, c) } +func (*fakeWrapper) WrapInput(w io.Writer, c schema.Cell, r render.RenderCellFunc) error { + return r(w, c) +} +func (*fakeWrapper) WrapOutput(w io.Writer, out schema.Outputter, r render.RenderCellFunc) error { + for _, c := range out.Outputs() { + if err := r(w, c); err != nil { + return err + } + } + return nil +} From bc5d78cd943cff84b860502650bf7dce3052b652 Mon Sep 17 00:00:00 2001 From: dyma solovei Date: Thu, 25 Jan 2024 14:46:51 +0100 Subject: [PATCH 8/8] fix(pkg/test): cast mime-bundle content to appropriate type --- pkg/test/cell.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/test/cell.go b/pkg/test/cell.go index 96c6379..5d139a3 100644 --- a/pkg/test/cell.go +++ b/pkg/test/cell.go @@ -166,7 +166,15 @@ func (mb mimebundle) MimeType() string { } func (mb mimebundle) Text() []byte { - return mb[mb.MimeType()].([]byte) + if txt, ok := mb[mb.MimeType()]; ok { + switch v := txt.(type) { + case []byte: + return v + case string: + return []byte(v) + } + } + return nil } func (mb mimebundle) PlainText() []byte {