diff --git a/benches/oneshot.rs b/benches/oneshot.rs
index 8a5cec1b1..e3b18390c 100644
--- a/benches/oneshot.rs
+++ b/benches/oneshot.rs
@@ -75,8 +75,7 @@ fn bench_eval(iai: &mut Iai) {
 fn bench_layout(iai: &mut Iai) {
     let (mut ctx, id) = context();
     let module = ctx.evaluate(id).unwrap();
-    let tree = module.into_root();
-    iai.run(|| tree.layout(&mut ctx));
+    iai.run(|| module.template.layout(&mut ctx));
 }
 
 fn bench_highlight(iai: &mut Iai) {
diff --git a/src/eval/mod.rs b/src/eval/mod.rs
index aa75f8b70..22bea7d1a 100644
--- a/src/eval/mod.rs
+++ b/src/eval/mod.rs
@@ -40,7 +40,7 @@ use unicode_segmentation::UnicodeSegmentation;
 use crate::diag::{At, Error, StrResult, Trace, Tracepoint, TypResult};
 use crate::geom::{Angle, Color, Fractional, Length, Paint, Relative};
 use crate::image::ImageStore;
-use crate::layout::RootNode;
+use crate::layout::Layout;
 use crate::library::{self, DecoLine, TextNode};
 use crate::loading::Loader;
 use crate::parse;
@@ -66,13 +66,6 @@ pub struct Module {
     pub template: Template,
 }
 
-impl Module {
-    /// Convert this module's template into a layout tree.
-    pub fn into_root(self) -> RootNode {
-        self.template.into_root()
-    }
-}
-
 /// Evaluate an expression.
 pub trait Eval {
     /// The output of evaluating the expression.
diff --git a/src/eval/template.rs b/src/eval/template.rs
index 6515c2713..90a751f33 100644
--- a/src/eval/template.rs
+++ b/src/eval/template.rs
@@ -8,11 +8,13 @@ use std::ops::{Add, AddAssign};
 use super::{Property, StyleMap, Styled};
 use crate::diag::StrResult;
 use crate::geom::SpecAxis;
-use crate::layout::{Layout, PackedNode, RootNode};
+use crate::layout::{Layout, PackedNode};
+use crate::library::prelude::*;
 use crate::library::{
     FlowChild, FlowNode, PageNode, ParChild, ParNode, PlaceNode, SpacingKind, TextNode,
 };
 use crate::util::EcoString;
+use crate::Context;
 
 /// Composable representation of styled content.
 ///
@@ -89,6 +91,18 @@ impl Template {
         Self::Block(node.pack())
     }
 
+    /// Layout this template into a collection of pages.
+    pub fn layout(&self, ctx: &mut Context) -> Vec<Arc<Frame>> {
+        let (mut ctx, styles) = LayoutContext::new(ctx);
+        let mut packer = Packer::new(true);
+        packer.walk(self.clone(), StyleMap::new());
+        packer
+            .into_root()
+            .iter()
+            .flat_map(|styled| styled.item.layout(&mut ctx, styled.map.chain(&styles)))
+            .collect()
+    }
+
     /// Style this template with a single property.
     pub fn styled<P: Property>(mut self, key: P, value: P::Value) -> Self {
         if let Self::Styled(_, map) = &mut self {
@@ -123,24 +137,6 @@ impl Template {
 
         Ok(Self::Sequence(vec![self.clone(); count]))
     }
-
-    /// Convert to a type-erased block-level node.
-    pub fn pack(self) -> PackedNode {
-        if let Template::Block(packed) = self {
-            packed
-        } else {
-            let mut packer = Packer::new(false);
-            packer.walk(self, StyleMap::new());
-            packer.into_block()
-        }
-    }
-
-    /// Lift to a root layout tree node.
-    pub fn into_root(self) -> RootNode {
-        let mut packer = Packer::new(true);
-        packer.walk(self, StyleMap::new());
-        packer.into_root()
-    }
 }
 
 impl Default for Template {
@@ -185,6 +181,27 @@ impl Sum for Template {
     }
 }
 
+impl Layout for Template {
+    fn layout(
+        &self,
+        ctx: &mut LayoutContext,
+        regions: &Regions,
+        styles: StyleChain,
+    ) -> Vec<Constrained<Arc<Frame>>> {
+        let mut packer = Packer::new(false);
+        packer.walk(self.clone(), StyleMap::new());
+        packer.into_block().layout(ctx, regions, styles)
+    }
+
+    fn pack(self) -> PackedNode {
+        if let Template::Block(packed) = self {
+            packed
+        } else {
+            PackedNode::new(self)
+        }
+    }
+}
+
 /// Packs a [`Template`] into a flow or root node.
 struct Packer {
     /// Whether this packer produces a root node.
@@ -215,9 +232,9 @@ impl Packer {
     }
 
     /// Finish up and return the resulting root node.
-    fn into_root(mut self) -> RootNode {
+    fn into_root(mut self) -> Vec<Styled<PageNode>> {
         self.pagebreak();
-        RootNode(self.pages)
+        self.pages
     }
 
     /// Consider a template with the given styles.
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 538bcd739..3cccee283 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -15,36 +15,14 @@ use std::fmt::{self, Debug, Formatter};
 use std::hash::{Hash, Hasher};
 use std::sync::Arc;
 
-use crate::eval::{StyleChain, Styled};
+use crate::eval::StyleChain;
 use crate::font::FontStore;
 use crate::frame::{Element, Frame, Geometry, Shape, Stroke};
 use crate::geom::{Align, Linear, Paint, Point, Sides, Size, Spec};
 use crate::image::ImageStore;
-use crate::library::{AlignNode, Move, PadNode, PageNode, TransformNode};
+use crate::library::{AlignNode, Move, PadNode, TransformNode};
 use crate::Context;
 
-/// The root layout node, a document consisting of top-level page runs.
-#[derive(Hash)]
-pub struct RootNode(pub Vec<Styled<PageNode>>);
-
-impl RootNode {
-    /// Layout the document into a sequence of frames, one per page.
-    pub fn layout(&self, ctx: &mut Context) -> Vec<Arc<Frame>> {
-        let (mut ctx, styles) = LayoutContext::new(ctx);
-        self.0
-            .iter()
-            .flat_map(|styled| styled.item.layout(&mut ctx, styled.map.chain(&styles)))
-            .collect()
-    }
-}
-
-impl Debug for RootNode {
-    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-        f.write_str("Root ")?;
-        f.debug_list().entries(&self.0).finish()
-    }
-}
-
 /// A node that can be layouted into a sequence of regions.
 ///
 /// Layout return one frame per used region alongside constraints that define
@@ -63,11 +41,7 @@ pub trait Layout {
     where
         Self: Debug + Hash + Sized + Sync + Send + 'static,
     {
-        PackedNode {
-            #[cfg(feature = "layout-cache")]
-            hash: self.hash64(),
-            node: Arc::new(self),
-        }
+        PackedNode::new(self)
     }
 }
 
@@ -86,8 +60,8 @@ pub struct LayoutContext<'a> {
 }
 
 impl<'a> LayoutContext<'a> {
-    /// Create a new layout context.
-    fn new(ctx: &'a mut Context) -> (Self, StyleChain<'a>) {
+    /// Create a new layout context and style chain.
+    pub fn new(ctx: &'a mut Context) -> (Self, StyleChain<'a>) {
         let this = Self {
             fonts: &mut ctx.fonts,
             images: &mut ctx.images,
@@ -100,27 +74,7 @@ impl<'a> LayoutContext<'a> {
     }
 }
 
-/// A layout node that produces an empty frame.
-///
-/// The packed version of this is returned by [`PackedNode::default`].
-#[derive(Debug, Hash)]
-pub struct EmptyNode;
-
-impl Layout for EmptyNode {
-    fn layout(
-        &self,
-        _: &mut LayoutContext,
-        regions: &Regions,
-        _: StyleChain,
-    ) -> Vec<Constrained<Arc<Frame>>> {
-        let size = regions.expand.select(regions.current, Size::zero());
-        let mut cts = Constraints::new(regions.expand);
-        cts.exact = regions.current.filter(regions.expand);
-        vec![Frame::new(size).constrain(cts)]
-    }
-}
-
-/// A packed layouting node with a precomputed hash.
+/// A type-erased layouting node with a precomputed hash.
 #[derive(Clone)]
 pub struct PackedNode {
     /// The type-erased node.
@@ -131,6 +85,18 @@ pub struct PackedNode {
 }
 
 impl PackedNode {
+    /// Pack any layoutable node.
+    pub fn new<T>(node: T) -> Self
+    where
+        T: Layout + Debug + Hash + Sync + Send + 'static,
+    {
+        Self {
+            #[cfg(feature = "layout-cache")]
+            hash: node.hash64(),
+            node: Arc::new(node),
+        }
+    }
+
     /// Check whether the contained node is a specific layout node.
     pub fn is<T: 'static>(&self) -> bool {
         self.node.as_any().is::<T>()
@@ -293,7 +259,7 @@ trait Bounds: Layout + Debug + Sync + Send + 'static {
 
 impl<T> Bounds for T
 where
-    T: Layout + Hash + Debug + Sync + Send + 'static,
+    T: Layout + Debug + Hash + Sync + Send + 'static,
 {
     fn as_any(&self) -> &dyn Any {
         self
@@ -309,13 +275,33 @@ where
     }
 }
 
+/// A layout node that produces an empty frame.
+///
+/// The packed version of this is returned by [`PackedNode::default`].
+#[derive(Debug, Hash)]
+struct EmptyNode;
+
+impl Layout for EmptyNode {
+    fn layout(
+        &self,
+        _: &mut LayoutContext,
+        regions: &Regions,
+        _: StyleChain,
+    ) -> Vec<Constrained<Arc<Frame>>> {
+        let size = regions.expand.select(regions.current, Size::zero());
+        let mut cts = Constraints::new(regions.expand);
+        cts.exact = regions.current.filter(regions.expand);
+        vec![Frame::new(size).constrain(cts)]
+    }
+}
+
 /// Fix the size of a node.
 #[derive(Debug, Hash)]
-pub struct SizedNode {
+struct SizedNode {
     /// How to size the node horizontally and vertically.
-    pub sizing: Spec<Option<Linear>>,
+    sizing: Spec<Option<Linear>>,
     /// The node to be sized.
-    pub child: PackedNode,
+    child: PackedNode,
 }
 
 impl Layout for SizedNode {
@@ -365,11 +351,11 @@ impl Layout for SizedNode {
 
 /// Fill the frames resulting from a node.
 #[derive(Debug, Hash)]
-pub struct FillNode {
+struct FillNode {
     /// How to fill the frames resulting from the `child`.
-    pub fill: Paint,
+    fill: Paint,
     /// The node to fill.
-    pub child: PackedNode,
+    child: PackedNode,
 }
 
 impl Layout for FillNode {
@@ -390,11 +376,11 @@ impl Layout for FillNode {
 
 /// Stroke the frames resulting from a node.
 #[derive(Debug, Hash)]
-pub struct StrokeNode {
+struct StrokeNode {
     /// How to stroke the frames resulting from the `child`.
-    pub stroke: Stroke,
+    stroke: Stroke,
     /// The node to stroke.
-    pub child: PackedNode,
+    child: PackedNode,
 }
 
 impl Layout for StrokeNode {
diff --git a/src/lib.rs b/src/lib.rs
index 32938cdac..99958aaca 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -2,14 +2,13 @@
 //!
 //! # Steps
 //! - **Parsing:** The parsing step first transforms a plain string into an
-//!   [iterator of tokens][tokens]. This token stream is [parsed] into a
-//!   [green tree]. The green tree itself is untyped, but a typed layer over it
-//!   is provided in the [AST] module.
+//!   [iterator of tokens][tokens]. This token stream is [parsed] into a [green
+//!   tree]. The green tree itself is untyped, but a typed layer over it is
+//!   provided in the [AST] module.
 //! - **Evaluation:** The next step is to [evaluate] the markup. This produces a
 //!   [module], consisting of a scope of values that were exported by the code
-//!   and a [template] with the contents of the module. This node can be
-//!   converted into a [layout tree], a hierarchical, styled representation of
-//!   the document. The nodes of this tree are well structured and
+//!   and a [template], a hierarchical, styled representation with the contents
+//!   of the module. The nodes of this tree are well structured and
 //!   order-independent and thus much better suited for layouting than the raw
 //!   markup.
 //! - **Layouting:** Next, the tree is [layouted] into a portable version of the
@@ -26,8 +25,7 @@
 //! [evaluate]: Context::evaluate
 //! [module]: eval::Module
 //! [template]: eval::Template
-//! [layout tree]: layout::RootNode
-//! [layouted]: layout::RootNode::layout
+//! [layouted]: eval::Template::layout
 //! [cache]: layout::LayoutCache
 //! [PDF]: export::pdf
 
@@ -127,8 +125,7 @@ impl Context {
     /// information.
     pub fn typeset(&mut self, id: SourceId) -> TypResult<Vec<Arc<Frame>>> {
         let module = self.evaluate(id)?;
-        let tree = module.into_root();
-        let frames = tree.layout(self);
+        let frames = module.template.layout(self);
         Ok(frames)
     }
 
diff --git a/tests/typeset.rs b/tests/typeset.rs
index 31610ffc1..59a8425fe 100644
--- a/tests/typeset.rs
+++ b/tests/typeset.rs
@@ -23,7 +23,7 @@ use typst::Context;
 use {
     filedescriptor::{FileDescriptor, StdioDescriptor::*},
     std::fs::File,
-    typst::layout::RootNode,
+    typst::eval::Template,
 };
 
 const TYP_DIR: &str = "./typ";
@@ -266,7 +266,7 @@ fn test_part(
     let id = ctx.sources.provide(src_path, src);
     let source = ctx.sources.get(id);
     if debug {
-        println!("Syntax: {:#?}", source.root())
+        println!("Syntax Tree: {:#?}", source.root())
     }
 
     let (local_compare_ref, mut ref_errors) = parse_metadata(&source);
@@ -276,15 +276,14 @@ fn test_part(
 
     let (frames, mut errors) = match ctx.evaluate(id) {
         Ok(module) => {
-            let tree = module.into_root();
             if debug {
-                println!("Layout: {tree:#?}");
+                println!("Template: {:#?}", module.template);
             }
 
-            let mut frames = tree.layout(ctx);
+            let mut frames = module.template.layout(ctx);
 
             #[cfg(feature = "layout-cache")]
-            (ok &= test_incremental(ctx, i, &tree, &frames));
+            (ok &= test_incremental(ctx, i, &module.template, &frames));
 
             if !compare_ref {
                 frames.clear();
@@ -484,7 +483,7 @@ fn test_reparse(src: &str, i: usize, rng: &mut LinearShift) -> bool {
 fn test_incremental(
     ctx: &mut Context,
     i: usize,
-    tree: &RootNode,
+    template: &Template,
     frames: &[Arc<Frame>],
 ) -> bool {
     let mut ok = true;
@@ -499,7 +498,7 @@ fn test_incremental(
 
         ctx.layout_cache.turnaround();
 
-        let cached = silenced(|| tree.layout(ctx));
+        let cached = silenced(|| template.layout(ctx));
         let total = reference.levels() - 1;
         let misses = ctx
             .layout_cache