diff --git a/.gitignore b/.gitignore
index ae20bd2..7133977 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,5 @@ dist
 
 # nanogen files
 example/_site
+example/_preview
 nanogen.log
diff --git a/nanogen/cli.py b/nanogen/cli.py
index b78f549..1206f7f 100644
--- a/nanogen/cli.py
+++ b/nanogen/cli.py
@@ -7,9 +7,6 @@ from nanogen import version
 from nanogen import models
 
 
-blog = models.Blog(os.getcwd())
-
-
 @click.group()
 @click.option('-v', '--verbose', count=True, help='Turn on verbose output.')
 @click.version_option(version=version.version)
@@ -20,18 +17,21 @@ def cli(verbose):
 @cli.command()
 def init():
     """Initialize the current directory."""
+    blog = models.Blog(os.getcwd())
     blog.init()
 
 
 @cli.command()
 def clean():
     """Clean any generated files."""
+    blog = models.Blog(os.getcwd())
     blog.clean()
 
 
 @cli.command()
 def build():
     """Start a build of the site."""
+    blog = models.Blog(os.getcwd())
     blog.build()
 
 
@@ -39,6 +39,7 @@ def build():
 @click.argument('title')
 def new(title):
     """Create a new post with the given title"""
+    blog = models.Blog(os.getcwd())
     try:
         blog.new_post(title)
     except ValueError as ve:
@@ -48,6 +49,7 @@ def new(title):
 @click.argument('title')
 def draft(title):
     """Create a new draft post with the given title"""
+    blog = models.Blog(os.getcwd())
     try:
         blog.new_post(title, draft=True)
     except ValueError as ve:
@@ -59,25 +61,27 @@ def draft(title):
 @click.option('-p', '--port', default=8080, type=int, help='The port to serve on')
 def preview(host, port):
     """Serve a preview of the site on HOST and PORT."""
-    site_dir = os.path.join(os.getcwd(), '_site')
-    if not os.path.isdir(site_dir):
-        click.ClickException('Unable to locate _site directory. Did you forget to run `nanogen build`?')
+    blog = models.Blog(os.getcwd(), is_preview=True)
+    blog.clean()
+    blog.build()
 
     try:
         import SimpleHTTPServer, BaseHTTPServer
         handler = SimpleHTTPServer.SimpleHTTPRequestHandler
         handler.protocol_version = 'HTTP/1.0'
-        httpd = BaseHTTPServer.HTTPServer((host, 8080), handler)
+        httpd = BaseHTTPServer.HTTPServer((host, port), handler)
     except ImportError:
         import http.server
         handler = http.server.SimpleHTTPRequestHandler
         handler.protocol_version = 'HTTP/1.0'
-        httpd = http.server.HTTPServer((host, 8080), handler)
+        httpd = http.server.HTTPServer((host, port), handler)
 
     try:
         click.secho('Serving your site on http://{host}:{port}/...'.format(host=host, port=port))
         click.secho('Press <Ctrl-C> to stop the server.\n')
-        os.chdir(site_dir)
+
+        os.chdir(blog.PATHS['preview'])
+
         httpd.serve_forever()
     except KeyboardInterrupt:
         httpd.server_close()
diff --git a/nanogen/models.py b/nanogen/models.py
index 368bd05..c9f55bf 100644
--- a/nanogen/models.py
+++ b/nanogen/models.py
@@ -73,17 +73,19 @@ class Post(object):
 
 
 class Blog(object):
-    def __init__(self, base_dir):
+    def __init__(self, base_dir, is_preview=False):
         self.PATHS = {
             'cwd': base_dir,
             'site': os.path.join(base_dir, '_site'),
+            'preview': os.path.join(base_dir, '_preview'),
             'posts': os.path.join(base_dir, '_posts'),
             'drafts': os.path.join(base_dir, '_drafts'),
             'layout': os.path.join(base_dir, '_layout')
         }
         
         self.config = self.parse_config()
-        self.posts = self.collect_posts()
+        self.output_dir = self.PATHS['preview'] if is_preview else self.PATHS['site']
+        self.posts = self.collect_posts(include_drafts=is_preview)
 
         jinja_loader = jinja2.FileSystemLoader(self.PATHS['layout'])
         self.jinja_env = jinja2.Environment(loader=jinja_loader)
@@ -100,10 +102,12 @@ class Blog(object):
         config.read(os.path.join(self.PATHS['cwd'], 'blog.cfg'))
         return config
 
-    def collect_posts(self):
+    def collect_posts(self, include_drafts=False):
         """
         Finds valid post files within the posts directory.
 
+        :param include_drafts: True if draft posts should be included
+        :type include_drafts: bool
         :return: A list of found posts
         :rtype: list
         """
@@ -113,9 +117,17 @@ class Blog(object):
 
         ls = os.listdir(self.PATHS['posts'])
         post_path = lambda path: os.path.join(self.PATHS['posts'], path)
-        return [Post(self.PATHS['site'], post_path(p))
-                for p in ls
-                if utils.is_valid_post_file(p)]
+        posts = [Post(self.output_dir, post_path(p))
+                 for p in ls
+                 if utils.is_valid_post_file(p)]
+
+        if include_drafts:
+            ls = os.listdir(self.PATHS['drafts'])
+            drafts_path = lambda path: os.path.join(self.PATHS['drafts'], path)
+            posts.extend([Post(self.output_dir, drafts_path(p))
+                          for p in ls
+                          if utils.is_valid_post_file(p)])
+        return posts
 
     def generate_posts(self):
         """
@@ -150,21 +162,26 @@ class Blog(object):
         posts = self.posts
 
         logger.log.debug('Rendering index.html')
-        filepath = os.path.join(self.PATHS['site'], 'index.html')
+        output_file = os.path.join(self.output_dir, 'index.html')
         template = self.jinja_env.get_template('index.html')
         html = template.render(site=self.config['site'], posts=list(reversed(posts)))
 
         logger.log.debug('Writing page to disk: index.html')
-        with open(filepath, 'w') as pout:
+        with open(output_file, 'w') as pout:
             pout.write(html)
 
     def generate_feeds(self):
+        """
+        Generate RSS and JSON feed files, if templates for them exist.
+
+        :return: None
+        """
         logger.log.debug('Writing feed pages...')
         posts = self.posts
 
         for feed in ('rss.xml', 'feed.json'):
             logger.log.debug('Rendering index.html')
-            filepath = os.path.join(self.PATHS['site'], feed)
+            output_file = os.path.join(self.output_dir, feed)
 
             try:
                 template = self.jinja_env.get_template(feed)
@@ -175,22 +192,25 @@ class Blog(object):
             html = template.render(site=self.config['site'], posts=list(reversed(posts)))
 
             logger.log.debug('Writing page to disk: %s', feed)
-            with open(filepath, 'w') as pout:
+            with open(output_file, 'w') as pout:
                 pout.write(html)
 
     def copy_static_files(self):
         """
-        Copy static files into the _sites directory.
+        Copy static files into the output directory.
 
         :return: None
         """
         layout_static = os.path.join(self.PATHS['layout'], 'static')
-        site_static = os.path.join(self.PATHS['site'], 'static')
+        output_static = os.path.join(self.output_dir, 'static')
+
+        if not os.path.isdir(layout_static):
+            return
 
-        if os.path.isdir(os.path.join(site_static)):
-            shutil.rmtree(site_static)
+        if os.path.isdir(os.path.join(output_static)):
+            shutil.rmtree(output_static)
 
-        shutil.copytree(layout_static, site_static)
+        shutil.copytree(layout_static, output_static)
 
     def init(self):
         """
@@ -216,13 +236,13 @@ class Blog(object):
 
     def build(self):
         """
-        Generate the site. Will create the _site dir if one doesn't already exist.
+        Generate the site. Will create the output dir if necessary.
 
         :return: None
         """
-        if not os.path.isdir(self.PATHS['site']):
-            logger.log.debug('Creating site directory...')
-            subprocess.call(['mkdir', self.PATHS['site']])
+        if not os.path.isdir(self.output_dir):
+            logger.log.debug('Creating output directory...')
+            subprocess.call(['mkdir', self.output_dir])
 
         self.generate_posts()
         self.generate_index_page()
@@ -236,9 +256,8 @@ class Blog(object):
         :return: None
         """
         logger.log.info('Cleaning generated files...')
-        site_dir = self.PATHS['site']
-        if os.path.isdir(site_dir):
-            subprocess.call(['rm', '-r', site_dir])
+        if os.path.isdir(self.output_dir):
+            subprocess.call(['rm', '-r', self.output_dir])
 
     def new_post(self, title, draft=False):
         """

Improve `preview` command
The `preview` command currently only serves up the `_site` directory, which is created by the `build` command. This is OK, but it doesn't allow for previewing draft posts. The `preview` command should instead:

1. Collect and generate all available posts
2. Collect and generate all available drafts
3. Spit them into a separate `_site_preview` folder
4. Serve that `_site_preview` folder instead of the `_site` folder

Notes:

* Perhaps the default behavior of the `preview` command remains the same, and this new "with drafts" behavior is available via a CLI flag?
diff --git a/tests/test_models.py b/tests/test_models.py
index a9e5080..8342ad0 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -224,6 +224,25 @@ def test_blog_build_and_clean(tmpdir):
     with mock.patch('subprocess.call'):
         blog.new_post('Test title 1', draft=False)
 
+    post_template = path.join('_layout').join('post.html')
+    post_template.write("""\
+    <!doctype html>
+    <html>
+    <body>Post template would go here.</body>
+    </html>
+    """)
+
+    index_template = path.join('_layout').join('index.html')
+    index_template.write("""\
+    <!doctype html>
+    <html>
+    <body>Index template would go here.</body>
+    </html>
+    """)
+
+    blog_config = path.join('_layout').join('blog.cfg')
+    blog_config.write(example_config)
+
     # Refresh the blog instance to better emulate real-world usage
     blog = models.Blog(str(path))
     blog.build()
@@ -239,3 +258,52 @@ def test_blog_build_and_clean(tmpdir):
 
     blog.clean()
     assert not os.path.isdir(str(site_path))
+
+
+def test_blog_build_and_clean_with_drafts(tmpdir):
+    path = tmpdir.mkdir('blog')
+    preview_path = path.mkdir('_preview')
+
+    # Set up a nanogen blog for posts
+    blog = models.Blog(str(path))
+    blog.init()
+
+    with mock.patch('subprocess.call'):
+        blog.new_post('Test post', draft=False)
+        blog.new_post('Draft post', draft=True)
+
+    post_template = path.join('_layout').join('post.html')
+    post_template.write("""\
+    <!doctype html>
+    <html>
+    <body>Post template would go here.</body>
+    </html>
+    """)
+
+    index_template = path.join('_layout').join('index.html')
+    index_template.write("""\
+    <!doctype html>
+    <html>
+    <body>Index template would go here.</body>
+    </html>
+    """)
+
+    blog_config = path.join('_layout').join('blog.cfg')
+    blog_config.write(example_config)
+
+    # Refresh the blog instance to better emulate real-world usage
+    blog = models.Blog(str(path), is_preview=True)
+    blog.build()
+
+    site_dir = [os.path.basename(str(file)) for file in preview_path.listdir()]
+    assert 'index.html' in site_dir
+
+    today = datetime.date.today()
+    expected_post_dir = preview_path.join('{}'.format(today.year)).join('{:02d}'.format(today.month))
+    generated_posts = [os.path.basename(str(file)) for file in expected_post_dir.listdir()]
+    assert len(generated_posts) == 2
+    assert 'test-post.html' in generated_posts
+    assert 'draft-post.html' in generated_posts
+
+    blog.clean()
+    assert not os.path.isdir(str(preview_path))

pip install -e .
parse_log_pytest
requirements.txt
pytest
3.6
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
certifi==2021.5.30
click==6.7
importlib-metadata==4.8.3
iniconfig==1.1.1
Jinja2==2.10
MarkupSafe==2.0.1
mistune==0.8.3
mistune-contrib==0.1
-e git+https://github.com/epochblue/nanogen.git@dfa6393e9c03290146be9d9e0676a40f84e517a5#egg=nanogen
packaging==21.3
pluggy==1.0.0
py==1.11.0
Pygments==2.2.0
pyparsing==3.1.4
pytest==7.0.1
tomli==1.2.3
typing_extensions==4.1.1
zipp==3.6.0

name: nanogen
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - click==6.7
      - importlib-metadata==4.8.3
      - iniconfig==1.1.1
      - jinja2==2.10
      - markupsafe==2.0.1
      - mistune==0.8.3
      - mistune-contrib==0.1
      - packaging==21.3
      - pluggy==1.0.0
      - py==1.11.0
      - pygments==2.2.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - tomli==1.2.3
      - typing-extensions==4.1.1
      - zipp==3.6.0
prefix: /opt/conda/envs/nanogen

tests/test_models.py::test_blog_build_and_clean_with_drafts
tests/test_models.py::test_post tests/test_models.py::test_blog_create tests/test_models.py::test_blog_init tests/test_models.py::test_blog_new_post tests/test_models.py::test_blog_new_draft tests/test_models.py::test_blog_copy_static_files tests/test_models.py::test_blog_generate_posts tests/test_models.py::test_blog_generate_index_page tests/test_models.py::test_blog_generate_feeds_no_feed_files tests/test_models.py::test_blog_feeds tests/test_models.py::test_blog_build_and_clean
nanogen/models.py .gitignore nanogen/cli.py
nanogen/models.py .gitignore nanogen/cli.py