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 new file mode 100644 index 0000000..e1bd30e --- /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/pkg/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/adapter/md.go b/extension/adapter/md.go new file mode 100644 index 0000000..ce11c3f --- /dev/null +++ b/extension/adapter/md.go @@ -0,0 +1,45 @@ +package adapter + +import ( + "io" + + "github.com/bevzzz/nb/render" + "github.com/bevzzz/nb/schema" +) + +// Blackfriday wraps [blackfriday]-style function in RenderCellFunc. +// +// Usage: +// +// extension.NewMarkdown( +// 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) (err error) { + _, err = w.Write(convert(cell.Text())) + return + } +} + +// Goldmark wraps [goldmark]-style function in RenderCellFunc. +// +// Usage: +// +// extension.NewMarkdown( +// adapter.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/extension_test.go b/extension/extension_test.go new file mode 100644 index 0000000..2e80c50 --- /dev/null +++ b/extension/extension_test.go @@ -0,0 +1,83 @@ +package extension_test + +import ( + "io" + "strings" + "testing" + + "github.com/bevzzz/nb" + "github.com/bevzzz/nb/extension" + "github.com/bevzzz/nb/pkg/test" + "github.com/bevzzz/nb/schema" + "github.com/stretchr/testify/require" +) + +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 + }), + ), + nb.WithRenderOptions(test.NoWrapper), + ) + r := c.Renderer() + + // Act + err := r.Render(&sb, test.Notebook(test.Markdown("Bye!"))) + require.NoError(t, err) + + // Assert + 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 + }), + ), + nb.WithRenderOptions(test.NoWrapper), + ) + r := c.Renderer() + + // 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) + } + }) + } +} diff --git a/extension/markdown.go b/extension/markdown.go new file mode 100644 index 0000000..a1b5799 --- /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/adapters offers elegant wrappers for some of the popular options: +// +// extension.NewMarkdown( +// adapter.Blackfriday(blackfriday.MarkdownCommon) +// ) +// +// or +// +// extension.NewMarkdown( +// adapter.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/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)) +} diff --git a/render/internal/test/cell.go b/pkg/test/cell.go similarity index 58% rename from render/internal/test/cell.go rename to pkg/test/cell.go index 250832f..5d139a3 100644 --- a/render/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,87 @@ 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 { + 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 { + if mb.MimeType() == common.PlainText { + return mb.Text() + } + 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 +} diff --git a/render/html/html_test.go b/render/html/html_test.go index 6796ee8..86ed678 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/pkg/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..db95d7d 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/pkg/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.go b/render/render.go index d238dab..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,13 +121,17 @@ 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, Render: rf, }) } - sort.Sort(r.renderCellFuncs) + r.renderCellFuncs.Sort() }) } @@ -251,6 +260,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) diff --git a/render/render_test.go b/render/render_test.go index 8ed9c42..a559a50 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" + "github.com/bevzzz/nb/pkg/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" 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"`