diff --git a/PKG-INFO b/PKG-INFO
index 4895ed9..ed572d9 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -9,39 +9,42 @@ License: BSD
 Project-URL: Bug Tracker, https://github.com/jupyter/jupyter-sphinx/issues/
 Project-URL: Documentation, https://jupyter-sphinx.readthedocs.io
 Project-URL: Source Code, https://github.com/jupyter/jupyter-sphinx/
-Description: # Jupyter Sphinx Extensions
-        
-        ``jupyter-sphinx`` enables running code embedded in Sphinx documentation and
-        embedding output of that code into the resulting document. It has support
-        for rich output such as images and even Jupyter interactive widgets.
-        
-        ## Installation
-        
-        With pip:
-        
-        ```bash
-        pip install jupyter_sphinx
-        ```
-        
-        with conda:
-        
-        ```bash
-        conda install jupyter_sphinx -c conda-forge
-        ```
-        
-        ## Usage
-        
-        You can check out the documentation on https://jupyter-sphinx.readthedocs.io for up to date
-        usage information and examples.
-        
-        
-        ## License
-        
-        We use a shared copyright model that enables all contributors to maintain the
-        copyright on their contributions.
-        
-        All code is licensed under the terms of the revised BSD license.
-        
 Platform: UNKNOWN
 Requires-Python: >= 3.6
 Description-Content-Type: text/markdown
+License-File: LICENSE
+
+# Jupyter Sphinx Extensions
+
+``jupyter-sphinx`` enables running code embedded in Sphinx documentation and
+embedding output of that code into the resulting document. It has support
+for rich output such as images and even Jupyter interactive widgets.
+
+## Installation
+
+With pip:
+
+```bash
+pip install jupyter_sphinx
+```
+
+with conda:
+
+```bash
+conda install jupyter_sphinx -c conda-forge
+```
+
+## Usage
+
+You can check out the documentation on https://jupyter-sphinx.readthedocs.io for up to date
+usage information and examples.
+
+
+## License
+
+We use a shared copyright model that enables all contributors to maintain the
+copyright on their contributions.
+
+All code is licensed under the terms of the revised BSD license.
+
+
diff --git a/jupyter_sphinx.egg-info/PKG-INFO b/jupyter_sphinx.egg-info/PKG-INFO
index 044162f..b7fb1b7 100644
--- a/jupyter_sphinx.egg-info/PKG-INFO
+++ b/jupyter_sphinx.egg-info/PKG-INFO
@@ -9,39 +9,42 @@ License: BSD
 Project-URL: Bug Tracker, https://github.com/jupyter/jupyter-sphinx/issues/
 Project-URL: Documentation, https://jupyter-sphinx.readthedocs.io
 Project-URL: Source Code, https://github.com/jupyter/jupyter-sphinx/
-Description: # Jupyter Sphinx Extensions
-        
-        ``jupyter-sphinx`` enables running code embedded in Sphinx documentation and
-        embedding output of that code into the resulting document. It has support
-        for rich output such as images and even Jupyter interactive widgets.
-        
-        ## Installation
-        
-        With pip:
-        
-        ```bash
-        pip install jupyter_sphinx
-        ```
-        
-        with conda:
-        
-        ```bash
-        conda install jupyter_sphinx -c conda-forge
-        ```
-        
-        ## Usage
-        
-        You can check out the documentation on https://jupyter-sphinx.readthedocs.io for up to date
-        usage information and examples.
-        
-        
-        ## License
-        
-        We use a shared copyright model that enables all contributors to maintain the
-        copyright on their contributions.
-        
-        All code is licensed under the terms of the revised BSD license.
-        
 Platform: UNKNOWN
 Requires-Python: >= 3.6
 Description-Content-Type: text/markdown
+License-File: LICENSE
+
+# Jupyter Sphinx Extensions
+
+``jupyter-sphinx`` enables running code embedded in Sphinx documentation and
+embedding output of that code into the resulting document. It has support
+for rich output such as images and even Jupyter interactive widgets.
+
+## Installation
+
+With pip:
+
+```bash
+pip install jupyter_sphinx
+```
+
+with conda:
+
+```bash
+conda install jupyter_sphinx -c conda-forge
+```
+
+## Usage
+
+You can check out the documentation on https://jupyter-sphinx.readthedocs.io for up to date
+usage information and examples.
+
+
+## License
+
+We use a shared copyright model that enables all contributors to maintain the
+copyright on their contributions.
+
+All code is licensed under the terms of the revised BSD license.
+
+
diff --git a/jupyter_sphinx.egg-info/requires.txt b/jupyter_sphinx.egg-info/requires.txt
index 4e8c6d0..f927e11 100644
--- a/jupyter_sphinx.egg-info/requires.txt
+++ b/jupyter_sphinx.egg-info/requires.txt
@@ -1,5 +1,5 @@
+IPython
 Sphinx>=2
 ipywidgets>=7.0.0
-IPython
 nbconvert>=5.5
 nbformat
diff --git a/jupyter_sphinx/__init__.py b/jupyter_sphinx/__init__.py
index 2cd40b9..0fb4c3a 100644
--- a/jupyter_sphinx/__init__.py
+++ b/jupyter_sphinx/__init__.py
@@ -13,15 +13,17 @@ from pathlib import Path
 from .ast import (
     JupyterCell,
     JupyterCellNode,
+    CellInput,
     CellInputNode,
+    CellOutput,
     CellOutputNode,
-    CellOutputBundleNode,
+    MimeBundleNode,
     JupyterKernelNode,
     JupyterWidgetViewNode,
     JupyterWidgetStateNode,
     WIDGET_VIEW_MIMETYPE,
     JupyterDownloadRole,
-    CellOutputsToNodes,
+    CombineCellInputOutput,
 )
 from .execute import JupyterKernel, ExecuteJupyterCells
 from .thebelab import ThebeButton, ThebeButtonNode, ThebeOutputNode, ThebeSourceNode
@@ -149,7 +151,7 @@ def setup(app):
     )
     app.add_config_value("jupyter_execute_default_kernel", "python3", "env")
     app.add_config_value(
-        "jupyter_execute_data_priority",
+        "render_priority_html",
         [
             WIDGET_VIEW_MIMETYPE,
             "application/javascript",
@@ -162,6 +164,17 @@ def setup(app):
         ],
         "env",
     )
+    app.add_config_value(
+        "render_priority_latex",
+        [
+            "image/svg+xml",
+            "image/png",
+            "image/jpeg",
+            "text/latex",
+            "text/plain",
+        ],
+        "env",
+    )
 
     # ipywidgets config
     app.add_config_value("jupyter_sphinx_require_url", REQUIRE_URL_DEFAULT, "html")
@@ -187,7 +200,7 @@ def setup(app):
     )
 
     # Register our container nodes, these should behave just like a regular container
-    for node in [JupyterCellNode, CellInputNode, CellOutputNode]:
+    for node in [JupyterCellNode, CellInputNode, CellOutputNode, MimeBundleNode]:
         app.add_node(
             node,
             override=True,
@@ -198,18 +211,6 @@ def setup(app):
             man=(render_container),
         )
 
-    # Register the output bundle node.
-    # No translators should touch this node because we'll replace it in a post-transform
-    app.add_node(
-        CellOutputBundleNode,
-        override=True,
-        html=(halt, None),
-        latex=(halt, None),
-        textinfo=(halt, None),
-        text=(halt, None),
-        man=(halt, None),
-    )
-
     # JupyterWidgetViewNode holds widget view JSON,
     # but is only rendered properly in HTML documents.
     app.add_node(
@@ -267,16 +268,18 @@ def setup(app):
 
     app.add_directive("jupyter-execute", JupyterCell)
     app.add_directive("jupyter-kernel", JupyterKernel)
+    app.add_directive("jupyter-input", CellInput)
+    app.add_directive("jupyter-output", CellOutput)
     app.add_directive("thebe-button", ThebeButton)
     app.add_role("jupyter-download:notebook", JupyterDownloadRole())
     app.add_role("jupyter-download:nb", JupyterDownloadRole())
     app.add_role("jupyter-download:script", JupyterDownloadRole())
+    app.add_transform(CombineCellInputOutput)
     app.add_transform(ExecuteJupyterCells)
-    app.add_transform(CellOutputsToNodes)
 
     # For syntax highlighting
     app.add_lexer("ipythontb", IPythonTracebackLexer)
-    app.add_lexer("ipython", IPython3Lexer)
+    app.add_lexer("ipython3", IPython3Lexer)
 
     app.connect("builder-inited", builder_inited)
     app.connect("build-finished", build_finished)
diff --git a/jupyter_sphinx/ast.py b/jupyter_sphinx/ast.py
index 7769aec..056b9e1 100644
--- a/jupyter_sphinx/ast.py
+++ b/jupyter_sphinx/ast.py
@@ -2,6 +2,7 @@
 
 import os
 import json
+import contextlib
 from pathlib import Path
 
 import docutils
@@ -12,6 +13,7 @@ from sphinx.util.docutils import ReferenceRole
 from sphinx.addnodes import download_reference
 from sphinx.transforms import SphinxTransform
 from sphinx.environment.collectors.asset import ImageCollector
+from sphinx.errors import ExtensionError
 
 import ipywidgets.embed
 import nbconvert
@@ -27,6 +29,51 @@ def csv_option(s):
     return [p.strip() for p in s.split(",")] if s else []
 
 
+def load_content(cell, location, logger):
+    if cell.arguments:
+        # As per 'sphinx.directives.code.LiteralInclude'
+        env = cell.state.document.settings.env
+        rel_filename, filename = env.relfn2path(cell.arguments[0])
+        env.note_dependency(rel_filename)
+        if cell.content:
+            logger.warning(
+                'Ignoring inline code in Jupyter cell included from "{}"'.format(
+                    rel_filename
+                ),
+                location=location,
+            )
+        try:
+            with Path(filename).open() as f:
+                content = [line.rstrip() for line in f.readlines()]
+        except (IOError, OSError):
+            raise IOError("File {} not found or reading it failed".format(filename))
+    else:
+        cell.assert_has_content()
+        content = cell.content
+    return content
+
+
+def get_highlights(cell, content, location, logger):
+    # The code fragment is taken from CodeBlock directive almost unchanged:
+    # https://github.com/sphinx-doc/sphinx/blob/0319faf8f1503453b6ce19020819a8cf44e39f13/sphinx/directives/code.py#L134-L148
+
+    emphasize_linespec = cell.options.get("emphasize-lines")
+    if emphasize_linespec:
+        nlines = len(content)
+        hl_lines = parselinenos(emphasize_linespec, nlines)
+        if any(i >= nlines for i in hl_lines):
+            logger.warning(
+                "Line number spec is out of range(1-{}): {}".format(
+                    nlines, emphasize_linespec
+                ),
+                location=location,
+            )
+        hl_lines = [i + 1 for i in hl_lines if i < nlines]
+    else:
+        hl_lines = []
+    return hl_lines
+
+
 class JupyterCell(Directive):
     """Define a code cell to be later executed in a Jupyter kernel.
 
@@ -56,7 +103,7 @@ class JupyterCell(Directive):
         If provided, the specified lines will be highlighted.
     raises : comma separated list of exception types
         If provided, a comma-separated list of exception type names that
-        the cell may raise. If one of the listed execption types is raised
+        the cell may raise. If one of the listed exception types is raised
         then the traceback is printed in place of the cell output. If an
         exception of another type is raised then we raise a RuntimeError
         when executing.
@@ -89,50 +136,16 @@ class JupyterCell(Directive):
 
         location = self.state_machine.get_source_and_line(self.lineno)
 
-        if self.arguments:
-            # As per 'sphinx.directives.code.LiteralInclude'
-            env = self.state.document.settings.env
-            rel_filename, filename = env.relfn2path(self.arguments[0])
-            env.note_dependency(rel_filename)
-            if self.content:
-                logger.warning(
-                    'Ignoring inline code in Jupyter cell included from "{}"'.format(
-                        rel_filename
-                    ),
-                    location=location,
-                )
-            try:
-                with Path(filename).open() as f:
-                    content = [line.rstrip() for line in f.readlines()]
-            except (IOError, OSError):
-                raise IOError("File {} not found or reading it failed".format(filename))
-        else:
-            self.assert_has_content()
-            content = self.content
-
-        # The code fragment is taken from CodeBlock directive almost unchanged:
-        # https://github.com/sphinx-doc/sphinx/blob/0319faf8f1503453b6ce19020819a8cf44e39f13/sphinx/directives/code.py#L134-L148
-
-        emphasize_linespec = self.options.get("emphasize-lines")
-        if emphasize_linespec:
-            try:
-                nlines = len(content)
-                hl_lines = parselinenos(emphasize_linespec, nlines)
-                if any(i >= nlines for i in hl_lines):
-                    logger.warning(
-                        "Line number spec is out of range(1-{}): {}".format(
-                            nlines, emphasize_linespec
-                        ),
-                        location=location,
-                    )
-                hl_lines = [i + 1 for i in hl_lines if i < nlines]
-            except ValueError as err:
-                return [self.state.document.reporter.warning(err, line=self.lineno)]
-        else:
-            hl_lines = []
+        content = load_content(self, location, logger)
+
+        try:
+            hl_lines = get_highlights(self, content, location, logger)
+        except ValueError as err:
+            return [self.state.document.reporter.warning(err, line=self.lineno)]
 
         # A top-level placeholder for our cell
         cell_node = JupyterCellNode(
+            execute=True,
             hide_code=("hide-code" in self.options),
             hide_output=("hide-output" in self.options),
             code_below=("code-below" in self.options),
@@ -152,6 +165,135 @@ class JupyterCell(Directive):
         cell_node += cell_input
         return [cell_node]
 
+class CellInput(Directive):
+    """Define a code cell to be included verbatim but not executed.
+
+    Arguments
+    ---------
+    filename : str (optional)
+        If provided, a path to a file containing code.
+
+    Options
+    -------
+    linenos : bool
+        If provided, the code will be shown with line numbering.
+    lineno-start: nonnegative int
+        If provided, the code will be show with line numbering beginning from
+        specified line.
+    emphasize-lines : comma separated list of line numbers
+        If provided, the specified lines will be highlighted.
+    
+    Content
+    -------
+    code : str
+        A code cell.
+    """
+
+    required_arguments = 0
+    optional_arguments = 1
+    final_argument_whitespace = True
+    has_content = True
+
+    option_spec = {
+        "linenos": directives.flag,
+        "lineno-start": directives.nonnegative_int,
+        "emphasize-lines": directives.unchanged_required,
+    }
+
+    def run(self):
+        # This only works lazily because the logger is inited by Sphinx
+        from . import logger
+
+        location = self.state_machine.get_source_and_line(self.lineno)
+
+        content = load_content(self, location, logger)
+
+        try:
+            hl_lines = get_highlights(self, content, location, logger)
+        except ValueError as err:
+            return [self.state.document.reporter.warning(err, line=self.lineno)]
+
+        # A top-level placeholder for our cell
+        cell_node = JupyterCellNode(
+            execute=False,
+            hide_code=False,
+            hide_output=True,
+            code_below=False,
+            emphasize_lines=hl_lines,
+            raises=False,
+            stderr=False,
+            classes=["jupyter_cell"],
+        )
+
+        # Add the input section of the cell, we'll add output when jupyter-execute cells are run
+        cell_input = CellInputNode(classes=["cell_input"])
+        cell_input += docutils.nodes.literal_block(
+            text="\n".join(content),
+            linenos=("linenos" in self.options),
+            linenostart=(self.options.get("lineno-start")),
+        )
+        cell_node += cell_input
+        return [cell_node]
+
+class CellOutput(Directive):
+    """Define an output cell to be included verbatim.
+
+    Arguments
+    ---------
+    filename : str (optional)
+        If provided, a path to a file containing output.
+
+    Content
+    -------
+    code : str
+        An output cell.
+    """
+
+    required_arguments = 0
+    optional_arguments = 1
+    final_argument_whitespace = True
+    has_content = True
+
+    option_spec = {}
+
+    def run(self):
+        # This only works lazily because the logger is inited by Sphinx
+        from . import logger
+
+        location = self.state_machine.get_source_and_line(self.lineno)
+
+        content = load_content(self, location, logger)
+
+        # A top-level placeholder for our cell
+        cell_node = JupyterCellNode(
+            execute=False,
+            hide_code=True,
+            hide_output=False,
+            code_below=False,
+            emphasize_lines=[],
+            raises=False,
+            stderr=False,
+        )
+
+        # Add a blank input and the given output to the cell
+        cell_input = CellInputNode(classes=["cell_input"])
+        cell_input += docutils.nodes.literal_block(
+            text="",
+            linenos=False,
+            linenostart=None,
+        )
+        cell_node += cell_input
+        content_str = "\n".join(content)
+        cell_output = CellOutputNode(classes=["cell_output"])
+        cell_output += docutils.nodes.literal_block(
+            text=content_str,
+            rawsource=content_str,
+            language="none",
+            classes=["output", "stream"],
+        )
+        cell_node += cell_output
+        return [cell_node]
+
 
 class JupyterCellNode(docutils.nodes.container):
     """Inserted into doctree whever a JupyterCell directive is encountered.
@@ -175,12 +317,35 @@ class CellOutputNode(docutils.nodes.container):
         super().__init__("", **attributes)
 
 
-class CellOutputBundleNode(docutils.nodes.container):
-    """Represent a MimeBundle in the Sphinx AST, to be transformed later."""
+class MimeBundleNode(docutils.nodes.container):
+    """A node with multiple representations rendering as the highest priority one."""
 
-    def __init__(self, outputs, rawsource="", *children, **attributes):
-        self.outputs = outputs
-        super().__init__("", **attributes)
+    def __init__(self, rawsource="", *children, **attributes):
+        super().__init__("", *children, mimetypes=attributes["mimetypes"])
+
+    def render_as(self, visitor):
+        """Determine which node to show based on the visitor"""
+        try:
+            # Or should we go to config via the node?
+            priority = visitor.builder.env.app.config[
+                'render_priority_' + visitor.builder.format
+            ]
+        except (AttributeError, KeyError):
+            # Not sure what do to, act as a container and show everything just in case.
+            return super()
+        for mimetype in priority:
+            try:
+                return self.children[self.attributes['mimetypes'].index(mimetype)]
+            except ValueError:
+                pass
+        # Same
+        return super()
+
+    def walk(self, visitor):
+        return self.render_as(visitor).walk(visitor)
+
+    def walkabout(self, visitor):
+        return self.render_as(visitor).walkabout(visitor)
 
 
 class JupyterKernelNode(docutils.nodes.Element):
@@ -223,23 +388,24 @@ class JupyterWidgetStateNode(docutils.nodes.Element):
         super().__init__("", state=attributes["state"])
 
     def html(self):
+        
+        # escape </script> to avoid early closing of the tag in the html page 
+        json_data = json.dumps(self["state"]).replace("</script>", r"<\/script>")
+        
         # TODO: render into a separate file if 'html-manager' starts fully
         #       parsing script tags, and not just grabbing their innerHTML
         # https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/html-manager/src/libembed.ts#L36
         return ipywidgets.embed.snippet_template.format(
-            load="", widget_views="", json_data=json.dumps(self["state"])
+            load="", widget_views="", json_data=json_data
         )
 
-
-def cell_output_to_nodes(outputs, data_priority, write_stderr, out_dir,
+def cell_output_to_nodes(outputs, write_stderr, out_dir,
                          thebe_config, inline=False):
     """Convert a jupyter cell with outputs and filenames to doctree nodes.
 
     Parameters
     ----------
     outputs : a list of outputs from a Jupyter cell
-    data_priority : list of mime types
-        Which media types to prioritize.
     write_stderr : bool
         If True include stderr in cell output
     out_dir : string
@@ -256,12 +422,7 @@ def cell_output_to_nodes(outputs, data_priority, write_stderr, out_dir,
         Each output, converted into a docutils node.
     """
     # If we're in `inline` mode, ensure that we don't add block-level nodes
-    if inline:
-        literal_node = docutils.nodes.literal
-        math_node = docutils.nodes.math
-    else:
-        literal_node = docutils.nodes.literal_block
-        math_node = math_block
+    literal_node = docutils.nodes.literal if inline else docutils.nodes.literal_block
 
     to_add = []
     for output in outputs:
@@ -277,7 +438,7 @@ def cell_output_to_nodes(outputs, data_priority, write_stderr, out_dir,
                     # Adds a "stderr" class that can be customized by the user for both
                     # the container and the literal_block.
                     #
-                    # Not setting "rawsource" disables Pygment hightlighting, which
+                    # Not setting "rawsource" disables Pygment highlighting, which
                     # would otherwise add a <div class="highlight">.
 
                     literal = literal_node(
@@ -314,72 +475,93 @@ def cell_output_to_nodes(outputs, data_priority, write_stderr, out_dir,
                 )
             )
         elif output_type in ("display_data", "execute_result"):
-            try:
-                # First mime_type by priority that occurs in output.
-                mime_type = next(x for x in data_priority if x in output["data"])
-            except StopIteration:
-                continue
-            data = output["data"][mime_type]
-            if mime_type.startswith("image"):
-                file_path = Path(output.metadata["filenames"][mime_type])
-                out_dir = Path(out_dir)
-                # Sphinx treats absolute paths as being rooted at the source
-                # directory, so make a relative path, which Sphinx treats
-                # as being relative to the current working directory.
-                filename = file_path.name
-
-                if out_dir in file_path.parents:
-                    out_dir = file_path.parent
-
-                uri = (out_dir / filename).as_posix()
-                to_add.append(docutils.nodes.image(uri=uri))
-            elif mime_type == "text/html":
-                to_add.append(
-                    docutils.nodes.raw(
-                        text=data, format="html", classes=["output", "text_html"]
-                    )
-                )
-            elif mime_type == "text/latex":
-                to_add.append(
-                    math_node(
-                        text=strip_latex_delimiters(data),
-                        nowrap=False,
-                        number=None,
-                        classes=["output", "text_latex"],
-                    )
+            children_by_mimetype = {
+                mime_type: output2sphinx(
+                    data, mime_type, output["metadata"], out_dir
                 )
-            elif mime_type == "text/plain":
-                to_add.append(
-                    literal_node(
-                        text=data,
-                        rawsource=data,
-                        language="none",
-                        classes=["output", "text_plain"],
-                    )
-                )
-            elif mime_type == "application/javascript":
-                to_add.append(
-                    docutils.nodes.raw(
-                        text='<script type="{mime_type}">{data}</script>'.format(
-                            mime_type=mime_type, data=data
-                        ),
-                        format="html",
-                    )
-                )
-            elif mime_type == WIDGET_VIEW_MIMETYPE:
-                to_add.append(JupyterWidgetViewNode(view_spec=data))
+                for mime_type, data in output["data"].items()
+
+            }
+            # Filter out unknown mimetypes
+            # TODO: rewrite this using walrus once we depend on Python 3.8
+            children_by_mimetype = {
+                mime_type: node
+                for mime_type, node in children_by_mimetype.items()
+                if node is not None
+            }
+            to_add.append(MimeBundleNode(
+                "",
+                *list(children_by_mimetype.values()),
+                mimetypes=list(children_by_mimetype.keys())
+            ))
 
     return to_add
 
 
-def attach_outputs(output_nodes, node, thebe_config):
+def output2sphinx(data, mime_type, metadata, out_dir, inline=False):
+    """Convert a Jupyter output with a specific mimetype to its sphinx representation."""
+    # This only works lazily because the logger is inited by Sphinx
+    from . import logger
+
+    # If we're in `inline` mode, ensure that we don't add block-level nodes
+    if inline:
+        literal_node = docutils.nodes.literal
+        math_node = docutils.nodes.math
+    else:
+        literal_node = docutils.nodes.literal_block
+        math_node = math_block
+
+    if mime_type == "text/html":
+        return docutils.nodes.raw(
+            text=data, format="html", classes=["output", "text_html"]
+        )
+    elif mime_type == "text/plain":
+        return literal_node(
+            text=data,
+            rawsource=data,
+            language="none",
+            classes=["output", "text_plain"],
+        )
+    elif mime_type == "text/latex":
+        return math_node(
+            text=strip_latex_delimiters(data),
+            nowrap=False,
+            number=None,
+            classes=["output", "text_latex"],
+        )
+    elif mime_type == "application/javascript":
+        return docutils.nodes.raw(
+            text='<script type="{mime_type}">{data}</script>'.format(
+                mime_type=mime_type, data=data
+            ),
+            format="html",
+        )
+    elif mime_type == WIDGET_VIEW_MIMETYPE:
+        return JupyterWidgetViewNode(view_spec=data)
+    elif mime_type.startswith("image"):
+        file_path = Path(metadata["filenames"][mime_type])
+        out_dir = Path(out_dir)
+        # Sphinx treats absolute paths as being rooted at the source
+        # directory, so make a relative path, which Sphinx treats
+        # as being relative to the current working directory.
+        filename = file_path.name
+
+        if out_dir in file_path.parents:
+            out_dir = file_path.parent
+
+        uri = (out_dir / filename).as_posix()
+        return docutils.nodes.image(uri=uri)
+    else:
+        logger.debug(f'Unknown mime type in cell output: {mime_type}')
+
+
+def apply_styling(node, thebe_config):
+    """Change the cell node appearance, according to its settings."""
     if not node.attributes["hide_code"]:  # only add css if code is displayed
         classes = node.attributes.get("classes", [])
         classes += ["jupyter_container"]
 
-    (input_node,) = node.traverse(CellInputNode)
-    (outputbundle_node,) = node.traverse(CellOutputBundleNode)
-    output_node = CellOutputNode(classes=["cell_output"])
+    (input_node, output_node) = node.children
     if thebe_config:
         # Move the source from the input node into the thebe_source node
         source = input_node.children.pop(0)
@@ -391,18 +573,15 @@ def attach_outputs(output_nodes, node, thebe_config):
         thebe_source.children = [source]
         input_node.children = [thebe_source]
 
-        if not node.attributes["hide_output"]:
-            thebe_output = ThebeOutputNode()
-            thebe_output.children = output_nodes
-            output_node += thebe_output
+        thebe_output = ThebeOutputNode()
+        thebe_output.children = output_node.children
+        output_node.children = [thebe_output]
     else:
         if node.attributes["hide_code"]:
             node.children.pop(0)
-        if not node.attributes["hide_output"]:
-            output_node.children = output_nodes
 
-    # Now replace the bundle with our OutputNode
-    outputbundle_node.replace_self(output_node)
+    if node.attributes["hide_output"]:
+        output_node.children = []
 
     # Swap inputs and outputs if we want the code below
     if node.attributes["code_below"]:
@@ -428,30 +607,39 @@ def get_widgets(notebook):
     try:
         return notebook.metadata.widgets[WIDGET_STATE_MIMETYPE]
     except AttributeError:
-        # Don't catch KeyError, as it's a bug if 'widgets' does
+        # Don't catch KeyError because it's a bug if 'widgets' does
         # not contain 'WIDGET_STATE_MIMETYPE'
         return None
 
 
-class CellOutputsToNodes(SphinxTransform):
-    """Use the builder context to transform a CellOutputNode into Sphinx nodes."""
+class CombineCellInputOutput(SphinxTransform):
+    """Merge nodes from CellOutput with the preceding CellInput node."""
 
-    default_priority = 700
+    default_priority = 120
 
     def apply(self):
-        thebe_config = self.config.jupyter_sphinx_thebelab_config
+        moved_outputs = set()
 
         for cell_node in self.document.traverse(JupyterCellNode):
-            (output_bundle_node,) = cell_node.traverse(CellOutputBundleNode)
-
-            # Create doctree nodes for cell outputs.
-            output_nodes = cell_output_to_nodes(
-                output_bundle_node.outputs,
-                self.config.jupyter_execute_data_priority,
-                bool(cell_node.attributes["stderr"]),
-                sphinx_abs_dir(self.env),
-                thebe_config,
-            )
-            # Remove the outputbundlenode and we'll attach the outputs next
-            attach_outputs(output_nodes, cell_node, thebe_config)
-
+            if cell_node.attributes["execute"] == False:
+                if cell_node.attributes["hide_code"] == False:
+                    # Cell came from jupyter-input
+                    sibling = cell_node.next_node(descend=False, siblings=True)
+                    if (
+                        isinstance(sibling, JupyterCellNode)
+                        and sibling.attributes["execute"] == False
+                        and sibling.attributes["hide_code"] == True
+                    ):
+                        # Sibling came from jupyter-output, so we merge
+                        cell_node += sibling.children[1]
+                        cell_node.attributes["hide_output"] = False
+                        moved_outputs.update({sibling})
+                else:
+                    # Call came from jupyter-output
+                    if cell_node not in moved_outputs:
+                        raise ExtensionError(
+                            "Found a jupyter-output node without a preceding jupyter-input"
+                        )
+
+        for output_node in moved_outputs:
+            output_node.replace_self([])
diff --git a/jupyter_sphinx/execute.py b/jupyter_sphinx/execute.py
index e6cc5db..798d340 100644
--- a/jupyter_sphinx/execute.py
+++ b/jupyter_sphinx/execute.py
@@ -46,14 +46,15 @@ from .utils import (
     output_directory,
     split_on,
     blank_nb,
+    sphinx_abs_dir,
 )
 from .ast import (
     JupyterCellNode,
-    CellOutputBundleNode,
+    CellOutputNode,
     JupyterKernelNode,
     cell_output_to_nodes,
     JupyterWidgetStateNode,
-    attach_outputs,
+    apply_styling,
     get_widgets,
 )
 
@@ -99,13 +100,14 @@ class JupyterKernel(Directive):
 class ExecuteJupyterCells(SphinxTransform):
     """Execute code cells in Jupyter kernels.
 
-   Traverses the doctree to find JupyterKernel and JupyterCell nodes,
-   then executes the code in the JupyterCell nodes in sequence, starting
-   a new kernel every time a JupyterKernel node is encountered. The output
-   from each code cell is inserted into the doctree.
-   """
+    Traverses the doctree to find JupyterKernel and JupyterCell nodes,
+    then executes the code in the JupyterCell nodes in sequence, starting
+    a new kernel every time a JupyterKernel node is encountered. The output
+    from each code cell is inserted into the doctree.
+    """
 
-    default_priority = 180  # An early transform, idk
+    # Beginning of main transforms. Not 100% sure it's the correct time.
+    default_priority = 400
 
     def apply(self):
         doctree = self.document
@@ -147,9 +149,11 @@ class ExecuteJupyterCells(SphinxTransform):
                 kernel_name = default_kernel
                 file_name = next(default_names)
 
+            # Add empty placeholder cells for non-executed nodes so nodes and cells can be zipped
+            # and the provided input/output can be inserted later
             notebook = execute_cells(
                 kernel_name,
-                [nbformat.v4.new_code_cell(node.astext()) for node in nodes],
+                [nbformat.v4.new_code_cell(node.astext() if node["execute"] else "") for node in nodes],
                 self.config.jupyter_execute_kwargs,
             )
 
@@ -164,7 +168,7 @@ class ExecuteJupyterCells(SphinxTransform):
                 raises_provided = node.attributes["raises"] is not None
                 if (
                     raises_provided and not allowed_errors
-                ):  # empty 'raises': supress all errors
+                ):  # empty 'raises': suppress all errors
                     pass
                 elif errors and not any(e["ename"] in allowed_errors for e in errors):
                     raise ExtensionError(
@@ -185,6 +189,16 @@ class ExecuteJupyterCells(SphinxTransform):
                         "Cell printed to stderr:\n{}".format(stderr[0]["text"])
                     )
 
+            # Insert input/output into placeholders for non-executed cells
+            for node, cell in zip(nodes, notebook.cells):
+                if not node["execute"]:
+                    cell.source = node.children[0].astext()
+                    if len(node.children) == 2:
+                        output = nbformat.v4.new_output("stream")
+                        output.text = node.children[1].astext()
+                        cell.outputs = [output]
+                        node.children.pop()
+
             try:
                 lexer = notebook.metadata.language_info.pygments_lexer
             except AttributeError:
@@ -192,7 +206,7 @@ class ExecuteJupyterCells(SphinxTransform):
 
             # Highlight the code cells now that we know what language they are
             for node in nodes:
-                source = node.children[0]
+                source = node.children[0].children[0]
                 source.attributes["language"] = lexer
 
             # Add line numbering
@@ -241,7 +255,17 @@ class ExecuteJupyterCells(SphinxTransform):
 
             # Add doctree nodes for cell outputs.
             for node, cell in zip(nodes, notebook.cells):
-                node += CellOutputBundleNode(cell.outputs)
+                # Add the outputs as children
+                output = CellOutputNode(classes=["cell_output"])
+                output.children = cell_output_to_nodes(
+                    cell.outputs,
+                    bool(node.attributes["stderr"]),
+                    sphinx_abs_dir(self.env),
+                    thebe_config,
+                )
+                node += output
+
+                apply_styling(node, thebe_config)
 
             if contains_widgets(notebook):
                 doctree.append(JupyterWidgetStateNode(state=get_widgets(notebook)))
@@ -298,25 +322,3 @@ def write_notebook_output(notebook, output_dir, notebook_name, location=None):
 def contains_widgets(notebook):
     widgets = get_widgets(notebook)
     return widgets and widgets["state"]
-
-
-def setup(app):
-    """A temporary setup function so that we can use it for
-    backwards compatability.
-
-    This should be removed after a deprecation cycle.
-    """
-    # To avoid circular imports we'll lazily import
-    from . import setup as jssetup
-
-    js.logger.warning(
-        (
-            "`jupyter-sphinx` was initialized with the "
-            "`jupyter_sphinx.execute` sub-module. Replace this with "
-            "`jupyter_sphinx`. Initializing with "
-            "`jupyter_sphinx.execute` will be removed in "
-            "version 0.3"
-        )
-    )
-    out = jssetup(app)
-    return out
diff --git a/jupyter_sphinx/thebelab.py b/jupyter_sphinx/thebelab.py
index f0cc50b..891d3b2 100644
--- a/jupyter_sphinx/thebelab.py
+++ b/jupyter_sphinx/thebelab.py
@@ -1,5 +1,4 @@
 """Inserting interactive links with Thebelab."""
-import os
 import json
 import docutils
 from docutils.parsers.rst import Directive
diff --git a/jupyter_sphinx/utils.py b/jupyter_sphinx/utils.py
index 23ec5a4..3cce372 100644
--- a/jupyter_sphinx/utils.py
+++ b/jupyter_sphinx/utils.py
@@ -57,7 +57,7 @@ def strip_latex_delimiters(source):
 
 
 def default_notebook_names(basename):
-    """Return an interator yielding notebook names based off 'basename'"""
+    """Return an iterator yielding notebook names based off 'basename'"""
     yield basename
     for i in count(1):
         yield "_".join((basename, str(i)))