Repository: epochblue/nanogen

Issue:
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?

Discussion:

Patch:
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):
         """


Test patch:
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))