diff --git a/pkg/test/render.go b/pkg/test/render.go index 78b65ba..860af44 100644 --- a/pkg/test/render.go +++ b/pkg/test/render.go @@ -17,6 +17,7 @@ type fakeWrapper struct{} var _ render.CellWrapper = (*fakeWrapper)(nil) func (*fakeWrapper) RegisterFuncs(render.RenderCellFuncRegistry) {} +func (*fakeWrapper) WrapAll(io.Writer, func(io.Writer) error) error { return nil } 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) diff --git a/render/html/html.go b/render/html/html.go index fa598c3..699c97a 100644 --- a/render/html/html.go +++ b/render/html/html.go @@ -15,10 +15,10 @@ type Config struct { type Option func(*Config) -// WithCSSWriter +// WithCSSWriter registers a writer for CSS stylesheet. func WithCSSWriter(w io.Writer) Option { return func(c *Config) { - c.CSSWriter = &WriterOnce{w: w} + c.CSSWriter = w } } @@ -77,18 +77,12 @@ func (r *Renderer) renderCode(w io.Writer, cell schema.Cell) error { return nil } - div.Open(w, attributes{"class": {"cm-editor", "cm-s-jupyter"}}, true) - div.Open(w, attributes{"class": {"highlight", "hl-ipython3"}}, true) - io.WriteString(w, "
")
 	w.Write(code.Text())
 	io.WriteString(w, "
") - div.Close(w) - div.Close(w) - return nil } diff --git a/render/html/html_test.go b/render/html/html_test.go index 86ed678..0b1e06f 100644 --- a/render/html/html_test.go +++ b/render/html/html_test.go @@ -66,6 +66,13 @@ func TestRenderer(t *testing.T) { "src": {"data:image/jpeg;base64, base64-encoded-image"}, }}, }, + { + name: "image/svg+xml", + cell: test.DisplayData("svg-image", "image/svg+xml"), + want: &node{tag: "img", attr: map[string][]string{ + "src": {"data:image/svg+xml;base64, svg-image"}, + }}, + }, { name: "code cell", cell: &test.CodeCell{ @@ -76,29 +83,14 @@ func TestRenderer(t *testing.T) { Lang: "python", }, want: &node{ - tag: "div", - attr: map[string][]string{ - "class": {"cm-editor", "cm-s-jupyter"}, - }, + tag: "pre", children: []*node{ { - tag: "div", + tag: "code", attr: map[string][]string{ - "class": {"highlight"}, - }, - children: []*node{ - { - tag: "pre", - children: []*node{ - { - tag: "code", - attr: map[string][]string{ - "class": {"language-python"}, - }, - content: "print('Hi, mom!')", - }, - }}, + "class": {"language-python"}, }, + content: "print('Hi, mom!')", }, }, }, @@ -123,20 +115,6 @@ func TestRenderer(t *testing.T) { } func TestRenderer_CSSWriter(t *testing.T) { - t.Run("WithCSSWriter wraps in WriterOnce", func(t *testing.T) { - // Arrange - var cfg html.Config - opt := html.WithCSSWriter(io.Discard) - - // Act - opt(&cfg) - - // Assert - if _, ok := cfg.CSSWriter.(*html.WriterOnce); !ok { - t.Errorf("expected *html.WriterOnce, got %T", cfg.CSSWriter) - } - }) - t.Run("captures correct css", func(t *testing.T) { // Arrange var css bytes.Buffer @@ -149,7 +127,7 @@ func TestRenderer_CSSWriter(t *testing.T) { } // Act - err = r.Wrap(io.Discard, test.Markdown(""), noopRender) + err = r.WrapAll(io.Discard, func(w io.Writer) error { return nil }) require.NoError(t, err) // Assert diff --git a/render/html/wrapper.go b/render/html/wrapper.go index 42987e2..d297a07 100644 --- a/render/html/wrapper.go +++ b/render/html/wrapper.go @@ -5,7 +5,6 @@ import ( "io" "sort" "strings" - "sync" "github.com/bevzzz/nb/render" "github.com/bevzzz/nb/schema" @@ -15,9 +14,7 @@ import ( /* TODO: - - make class prefixes configurable (probably on the html.Renderer level). - - refactor to use tagger - - add WrapAll / WrapNotebook that would add a
(then there's no need for WriterOnce) + - make class prefixes configurable (probably on the html.Config level). */ // Wrapper wraps cells in the HTML produced by the original Jupyter's nbconvert. @@ -25,11 +22,24 @@ type Wrapper struct { Config } -func (wr *Wrapper) Wrap(w io.Writer, cell schema.Cell, render render.RenderCellFunc) error { +var _ render.CellWrapper = (*Wrapper)(nil) + +func (wr *Wrapper) WrapAll(w io.Writer, render func(io.Writer) error) error { + tag := tagger{Writer: w} + defer tag.Close() + if wr.CSSWriter != nil { wr.CSSWriter.Write(jupyterCSS) } + tag.Open("div", attributes{"class": {"jp-Notebook"}}) + return render(w) +} + +func (wr *Wrapper) Wrap(w io.Writer, cell schema.Cell, render render.RenderCellFunc) error { + tag := tagger{Writer: w} + defer tag.Close() + var ct string switch cell.Type() { case schema.Markdown: @@ -41,73 +51,73 @@ func (wr *Wrapper) Wrap(w io.Writer, cell schema.Cell, render render.RenderCellF ct = "jp-RawCell" } - div.Open(w, attributes{"class": {"jp-Cell", ct, "jp-Notebook-cell"}}, true) + tag.Open("div", attributes{"class": {"jp-Cell", ct, "jp-Notebook-cell"}}) render(w, cell) - div.Close(w) return nil } func (wr *Wrapper) WrapInput(w io.Writer, cell schema.Cell, render render.RenderCellFunc) error { - div.Open(w, attributes{ + tag := tagger{Writer: w} + defer tag.Close() + + tag.Open("div", attributes{ "class": {"jp-Cell-inputWrapper"}, - "tabindex": {0}}, true) + "tabindex": {0}}) - div.Open(w, attributes{"class": {"jp-Collapser", "jp-InputCollapser", "jp-Cell-inputCollapser"}}, true) + tag.Open("div", attributes{"class": {"jp-Collapser", "jp-InputCollapser", "jp-Cell-inputCollapser"}}) io.WriteString(w, " ") - div.Close(w) + tag.CloseLast() // TODO: add collapser-child
and collapsing functionality // Pure CSS Collapsible: https://www.digitalocean.com/community/tutorials/css-collapsible - div.Open(w, attributes{"class": {"jp-InputArea", "jp-Cell-inputArea"}}, true) + tag.Open("div", attributes{"class": {"jp-InputArea", "jp-Cell-inputArea"}}) // Prompt In:[1] - div.Open(w, attributes{"class": {"jp-InputPrompt", "jp-InputArea-prompt"}}, false) + tag.OpenInline("div", attributes{"class": {"jp-InputPrompt", "jp-InputArea-prompt"}}) if ex, ok := cell.(interface{ ExecutionCount() int }); ok { fmt.Fprintf(w, "In\u00a0[%d]:", ex.ExecutionCount()) } - div.Close(w) + tag.CloseLast() - isCode := cell.Type() == schema.Code - isMd := cell.Type() == schema.Markdown - if isCode { - div.Open(w, attributes{ + switch cell.Type() { + case schema.Code: + tag.Open("div", attributes{ "class": { "jp-CodeMirrorEditor", "jp-Editor", "jp-InputArea-editor", }, "data-type": {"inline"}, - }, true) - } else if isMd { - div.Open(w, attributes{ + }) + + tag.Open("div", attributes{"class": {"cm-editor", "cm-s-jupyter"}}) + tag.Open("div", attributes{"class": {"highlight", "hl-ipython3"}}) + + case schema.Markdown: + tag.Open("div", attributes{ "class": { "jp-RenderedMarkdown", "jp-MarkdownOutput", "jp-RenderedHTMLCommon", }, "data-mime-type": {common.MarkdownText}, - }, true) + }) } - // Cell itself _ = render(w, cell) - - if isCode || isMd { - div.Close(w) - } - - div.Close(w) - div.Close(w) return nil } func (wr *Wrapper) WrapOutput(w io.Writer, cell schema.Outputter, render render.RenderCellFunc) error { - div.Open(w, attributes{"class": {"jp-Cell-outputWrapper"}}, true) - div.OpenClose(w, attributes{"class": {"jp-Collapser", "jp-OutputCollapser", "jp-Cell-outputCollapser"}}) - div.Open(w, attributes{"class": {"jp-OutputArea jp-Cell-outputArea"}}, true) + tag := tagger{Writer: w} + defer tag.Close() + + tag.Open("div", attributes{"class": {"jp-Cell-outputWrapper"}}) + tag.OpenInline("div", attributes{"class": {"jp-Collapser", "jp-OutputCollapser", "jp-Cell-outputCollapser"}}) + tag.CloseLast() + tag.Open("div", attributes{"class": {"jp-OutputArea jp-Cell-outputArea"}}) - // TODO: see how application/json would be handled // TODO: jp-RenderedJavaScript is a thing and so is jp-RenderedLatex (but I don't think we need to do anything about the latter) var child bool @@ -141,99 +151,101 @@ func (wr *Wrapper) WrapOutput(w io.Writer, cell schema.Outputter, render render. } else if strings.HasPrefix(datamimetype, "image/") { renderedClass = "jp-RenderedImage" child = true - } else if datamimetype == "application/vnd.jupyter.stderr" { + } else if datamimetype == common.Stderr { renderedClass = "jp-RenderedText" } - // Looks like this will always wrap the whole output area! if child { - div.Open(w, attributes{"class": {childClass}}, true) + tag.Open("div", attributes{"class": {childClass}}) } - div.Open(w, attributes{"class": {"jp-OutputPrompt", "jp-OutputArea-prompt"}}, false) + tag.OpenInline("div", attributes{"class": {"jp-OutputPrompt", "jp-OutputArea-prompt"}}) for _, out := range cell.Outputs() { if ex, ok := out.(interface{ ExecutionCount() int }); ok { fmt.Fprintf(w, "Out\u00a0[%d]:", ex.ExecutionCount()) break } } - div.Close(w) + tag.CloseLast() - div.Open(w, attributes{ + tag.Open("div", attributes{ "class": {renderedClass, "jp-OutputArea-output", outputtypeclass}, "data-mime-type": {datamimetype}, - }, true) + }) for _, out := range cell.Outputs() { _ = render(w, out) } - div.Close(w) - - if child { - div.Close(w) - } - - div.Close(w) - div.Close(w) + tag.CloseLast() return nil } -const ( - div tag = "div" -) - -type tag string - -// Open the tag with the attributes, e.g.
. -func (t tag) Open(w io.Writer, attrs attributes, newline bool) { - t._open(w, attrs, newline) -} - -func (t tag) _open(w io.Writer, attrs attributes, newline bool) { - io.WriteString(w, "<") - io.WriteString(w, string(t)) - attrs.WriteTo(w) - io.WriteString(w, ">") - if newline { - io.WriteString(w, "\n") - } +// tagger is a straightforward utility for writing HTML tags. +// +// Example: +// +// tag := tagger{Writer: os.Stdout} +// defer tag.Close() +// tag.Open("div", attributes{"class": {"box"}}) +// tag.Open("pre", attributes{"class": {"hl", "python"}}) +// +// tagger also supports empty tags. +type tagger struct { + Writer io.Writer + opened []string } -func (t tag) Close(w io.Writer) { - fmt.Fprintf(w, "\n", t) +// Open opens the tag with the attributes. +func (t *tagger) Open(tag string, attr attributes) { + t.openTag(tag, attr, true) } -func (t tag) OpenClose(w io.Writer, attrs attributes) { - t._open(w, attrs, false) - t.Close(w) +// Open inline does not add a newline '\n' after the opening tag. +func (t *tagger) OpenInline(tag string, attr attributes) { + t.openTag(tag, attr, false) } -// Empty writes the attributes in an empty-element tag, e.g.
. -func (t tag) Empty(w io.Writer, attrs attributes) { - io.WriteString(w, "<") - io.WriteString(w, string(t)) - attrs.WriteTo(w) - io.WriteString(w, " />") +// Empty creates an empty HTML tag, like . +func (t *tagger) Empty(tag string, attr attributes) { + io.WriteString(t.Writer, "<") + io.WriteString(t.Writer, tag) + attr.WriteTo(t.Writer) + io.WriteString(t.Writer, " />") } -type tagger struct { - opened []tag -} - -// Open opens the tag with the attributes. -func (t *tagger) Open(tag tag, w io.Writer, attr attributes) { - tag.Open(w, attr, true) // TODO: redo +func (t *tagger) openTag(tag string, attr attributes, newline bool) { + io.WriteString(t.Writer, "<") + io.WriteString(t.Writer, tag) + attr.WriteTo(t.Writer) + io.WriteString(t.Writer, ">") + if newline { + io.WriteString(t.Writer, "\n") + } t.opened = append(t.opened, tag) } // Close closes all opened tags in reverse order. -func (t *tagger) Close(w io.Writer) { +// Always adds a newline after the tag. +func (t *tagger) Close() { l := len(t.opened) if l == 0 { return } for i := l - 1; i >= 0; i-- { - t.opened[i].Close(w) + t.closeTag(t.opened[i]) + } +} + +func (t *tagger) CloseLast() { + l := len(t.opened) + if l == 0 { + return } + t.closeTag(t.opened[l-1]) + t.opened = t.opened[:l-1] +} + +func (t *tagger) closeTag(tag string) { + fmt.Fprintf(t.Writer, "\n", tag) } type attributes map[string][]interface{} @@ -294,19 +306,3 @@ func (attrs attributes) WriteTo(w io.Writer) (n64 int64, err error) { } return } - -// WriterOnce writes to the writer once only. -// TODO: move to util -type WriterOnce struct { - w io.Writer - once sync.Once -} - -var _ io.Writer = (*WriterOnce)(nil) - -func (w *WriterOnce) Write(p []byte) (n int, err error) { - w.once.Do(func() { - n, err = w.w.Write(p) - }) - return -} diff --git a/render/html/wrapper_test.go b/render/html/wrapper_test.go index db95d7d..e64ea38 100644 --- a/render/html/wrapper_test.go +++ b/render/html/wrapper_test.go @@ -19,6 +19,22 @@ import ( func noopRender(w io.Writer, c schema.Cell) error { return nil } +func TestWrapper_WrapAll(t *testing.T) { + // Arrange + var w html.Wrapper + var buf bytes.Buffer + want := node{tag: "div", attr: map[string][]string{ + "class": {"jp-Notebook"}, + }} + + // Act + err := w.WrapAll(&buf, func(w io.Writer) error { return nil }) + require.NoError(t, err) + + // Assert + checkDOM(t, &buf, &want) +} + func TestWrapper_Wrap(t *testing.T) { for _, tt := range []struct { name string @@ -201,6 +217,22 @@ func TestWrapper_WrapInput(t *testing.T) { }, "data-type": {"inline"}, }, + children: []*node{ + { + tag: "div", + attr: map[string][]string{ + "class": {"cm-editor", "cm-s-jupyter"}, + }, + children: []*node{ + { + tag: "div", + attr: map[string][]string{ + "class": {"highlight"}, + }, + }, + }, + }, + }, }, }, }, @@ -686,7 +718,7 @@ func findFirst(n *stdhtml.Node, target string) *stdhtml.Node { // Add modifications are done on the copy, so the original node is not modified. func dropChildElements(n *stdhtml.Node) *stdhtml.Node { cp := *n - if cp.FirstChild != nil && cp.FirstChild.Type != stdhtml.TextNode { + if fc := cp.FirstChild; fc != nil && (fc.Type != stdhtml.TextNode || fc.Data == "\n") { cp.FirstChild = nil } cp.NextSibling = nil diff --git a/render/render.go b/render/render.go index b55a937..2547ddc 100644 --- a/render/render.go +++ b/render/render.go @@ -70,14 +70,19 @@ func WithCellRenderers(crs ...CellRenderer) Option { // CellWrapper renders common wrapping elements for every cell type. type CellWrapper interface { - // Wrap the entire cell block. + // Wrap the entire cell. Wrap(io.Writer, schema.Cell, RenderCellFunc) error - // Wrap input block. + // WrapInput wraps input block. WrapInput(io.Writer, schema.Cell, RenderCellFunc) error - // Wrap output block (code cells). + // WrapOuput wraps output block (code cells). WrapOutput(io.Writer, schema.Outputter, RenderCellFunc) error + + // WrapAll wraps all cells in the notebook. + // This method will be called once and will receive a function + // to render the rest of the notebook. + WrapAll(io.Writer, func(io.Writer) error) error } // renderer is a base Renderer implementation.