diff --git a/.gitignore b/.gitignore
index 119f9d6..ae20bd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@
 *.egg-info
 build
 dist
+.pytest_cache
 
 # nanogen files
 example/_site
diff --git a/nanogen/cli.py b/nanogen/cli.py
index b8cac8f..b78f549 100644
--- a/nanogen/cli.py
+++ b/nanogen/cli.py
@@ -7,7 +7,7 @@ from nanogen import version
 from nanogen import models
 
 
-blog = models.Blog()
+blog = models.Blog(os.getcwd())
 
 
 @click.group()
@@ -42,7 +42,7 @@ def new(title):
     try:
         blog.new_post(title)
     except ValueError as ve:
-        click.ClickException(ve.message)
+        click.ClickException(str(ve))
 
 @cli.command()
 @click.argument('title')
@@ -51,7 +51,7 @@ def draft(title):
     try:
         blog.new_post(title, draft=True)
     except ValueError as ve:
-        click.ClickException(ve.message)
+        click.ClickException(str(ve))
 
 
 @cli.command()
diff --git a/nanogen/models.py b/nanogen/models.py
index 481ebae..8acb8bc 100644
--- a/nanogen/models.py
+++ b/nanogen/models.py
@@ -35,12 +35,16 @@ class Post(object):
             self.raw_content = p.read()
 
         lines = self.raw_content.strip().splitlines()
-        self.title = lines[0].lstrip('#')
+        self.title = lines[0].lstrip('#').strip()
         self.markdown_content = '\n'.join(lines[2:]).strip()
         self.html_content = renderer.markdown(self.markdown_content)
 
     def __repr__(self):
-        return u'{}(path={})'.format(self.__class__.__name__, self.path)
+        return u'{}(base_path={}, path_to_file={})'.format(
+            self.__class__.__name__,
+            self.base_path,
+            self.path
+        )
 
     @property
     def pub_date(self):
@@ -64,19 +68,19 @@ class Post(object):
     @property
     def permalink(self):
         dt = self.pub_date
-        return '/{}/{:02d}/{}'.format(dt.year, dt.month, self.html_filename)
+        return os.path.join(str(dt.year), '{:02d}'.format(dt.month), self.html_filename)
 
 
 class Blog(object):
-    PATHS = {
-        'cwd': os.getcwd(),
-        'site': os.path.join(os.getcwd(), '_site'),
-        'posts': os.path.join(os.getcwd(), '_posts'),
-        'drafts': os.path.join(os.getcwd(), '_drafts'),
-        'layout': os.path.join(os.getcwd(), '_layout')
-    }
-
-    def __init__(self):
+    def __init__(self, base_dir):
+        self.PATHS = {
+            'cwd': base_dir,
+            'site': os.path.join(base_dir, '_site'),
+            '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()
 
@@ -198,7 +202,7 @@ class Blog(object):
                 subprocess.call(['mkdir', d])
 
         # Generate template blog configuration file
-        config_path = os.path.join(self.PATHS['layout'], 'blog.cfg')
+        config_path = os.path.join(self.PATHS['cwd'], 'blog.cfg')
         if not os.path.exists(config_path):
             with open(config_path, 'w') as f:
                 text = textwrap.dedent("""\
diff --git a/nanogen/utils.py b/nanogen/utils.py
index cd96f41..82bc975 100644
--- a/nanogen/utils.py
+++ b/nanogen/utils.py
@@ -22,6 +22,9 @@ def is_valid_post_file(basename):
     post_pattern = r'^\d{4}-\d{2}-\d{2}-.*'
     markdown_extensions = ['md', 'markdown', 'mdown']
 
+    if '.' not in basename:
+        return False
+
     filename, ext = os.path.basename(basename).rsplit('.', 1)
 
     ignorable = filename.startswith('_')
diff --git a/setup.py b/setup.py
index 56de568..ab1ec73 100644
--- a/setup.py
+++ b/setup.py
@@ -3,6 +3,17 @@ from setuptools import setup
 
 from nanogen.version import version
 
+install_requires = [
+    'click==6.7',
+    'mistune==0.8.3',
+    'Jinja2==2.10',
+    'Pygments==2.2.0'
+]
+
+dev_requires = [
+    'pytest==3.4.1'
+]
+
 entry_points = {
     'console_scripts': ['nanogen = nanogen.cli:cli']
 }
@@ -23,12 +34,10 @@ setup(name='nanogen',
       license='MIT',
       url='https://github.com/epochblue/nanogen',
       py_modules=['nanogen'],
-      install_requires=[
-          'click==6.7',
-          'mistune==0.8.3',
-          'Jinja2==2.10',
-          'Pygments==2.2.0'
-      ],
+      install_requires=install_requires,
+      extras_require={
+          'dev': dev_requires,
+      },
       entry_points=entry_points,
       keywords=['command line', 'static generator', 'blog'],
       classifiers=[

Add tests
`nanogen` needs some manner of tests to ensure correctness, but right now it has zero. That's just not OK, y'all.
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000..604c2ac
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,280 @@
+import datetime
+import os
+from unittest import mock
+
+from nanogen import models
+
+example_post = """\
+# Test Post
+
+And this is my _markdown_ **content**.
+
+Look, it also has:
+
+* an
+* unordered
+* list
+"""
+
+example_config = """\
+[site]
+author = Example user
+email = user@example.com
+description = A test description
+url = http://www.example.com
+title = Test Example
+"""
+
+
+def test_post(tmpdir):
+    f = tmpdir.mkdir('blog').join('2018-01-01-test-post.md')
+    f.write(example_post)
+
+    file_path = os.path.join(tmpdir, 'blog', '2018-01-01-test-post.md')
+    p = models.Post(tmpdir, file_path)
+
+    assert p.filename == '2018-01-01-test-post.md'
+    assert p.title == 'Test Post'
+    assert p.raw_content == example_post
+    expected_markdown = example_post.strip().splitlines()
+    assert p.markdown_content == '\n'.join(expected_markdown[2:])
+    assert p.pub_date == datetime.datetime(2018, 1, 1, 0, 0, 0)
+    assert p.slug == 'test-post'
+    assert p.html_filename == 'test-post.html'
+    assert p.permapath == os.path.join(tmpdir, '2018', '01', 'test-post.html')
+    assert p.permalink == os.path.join('2018', '01', 'test-post.html')
+
+
+def test_blog_create(tmpdir):
+    path = tmpdir.mkdir('blog')
+    config_file = path.join('blog.cfg')
+    config_file.write(example_config)
+    blog = models.Blog(path)
+    assert len(blog.posts) == 0
+    assert blog.config['site']['author'] == 'Example user'
+    assert blog.config['site']['email'] == 'user@example.com'
+    assert blog.config['site']['description'] == 'A test description'
+    assert blog.config['site']['url'] == 'http://www.example.com'
+    assert blog.config['site']['title'] == 'Test Example'
+
+
+def test_blog_init(tmpdir):
+    path = tmpdir.mkdir('blog')
+    blog = models.Blog(path)
+    blog.init()
+
+    listing = [os.path.basename(file) for file in path.listdir()]
+    assert len(listing) == 4
+    assert 'blog.cfg' in listing
+    assert '_layout' in listing
+    assert '_posts' in listing
+    assert '_drafts' in listing
+
+
+def test_blog_new_post(tmpdir):
+    path = tmpdir.mkdir('blog')
+    blog = models.Blog(path)
+    blog.init()
+
+    before_posts = blog.collect_posts()
+    assert len(before_posts) == 0
+
+    with mock.patch('subprocess.call'):
+        blog.new_post('Test title', draft=False)
+
+    after_posts = blog.collect_posts()
+    assert len(after_posts) == 1
+    today = datetime.date.today()
+    expected_filename = f'{today.year}-{today.month:02d}-{today.day:02d}-test-title.md'
+    assert after_posts[0].filename == expected_filename
+
+
+def test_blog_new_draft(tmpdir):
+    path = tmpdir.mkdir('blog')
+    blog = models.Blog(path)
+    blog.init()
+
+    before_posts = blog.collect_posts()
+    assert len(before_posts) == 0
+
+    with mock.patch('subprocess.call'):
+        blog.new_post('Test title', draft=True)
+
+    after_posts = blog.collect_posts()
+    assert len(after_posts) == 0
+
+
+def test_blog_copy_static_files(tmpdir):
+    path = tmpdir.mkdir('blog')
+    site_path = path.mkdir('_site')
+
+    # Add a static file to the projet
+    blog = models.Blog(path)
+    blog.init()
+    css_file = path.join('_layout').mkdir('static').join('example.css')
+    css_file.write('# CSS goes here')
+    blog.copy_static_files()
+
+    site_static_path = site_path.join('static')
+    static_files = [os.path.basename(file) for file in site_static_path.listdir()]
+    assert 'example.css' in static_files
+
+
+def test_blog_generate_posts(tmpdir):
+    path = tmpdir.mkdir('blog')
+    site_path = path.mkdir('_site')
+
+    # Set up a nanogen blog for posts
+    blog = models.Blog(path)
+    blog.init()
+
+    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>Single post 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(path)
+    blog.generate_posts()
+
+    today = datetime.date.today()
+    expected_post_dir = site_path.join(f'{today.year}').join(f'{today.month:02d}')
+    generated_posts = [os.path.basename(file) for file in expected_post_dir.listdir()]
+    assert len(generated_posts) == 1
+    assert 'test-title-1.html' in generated_posts
+
+
+def test_blog_generate_index_page(tmpdir):
+    path = tmpdir.mkdir('blog')
+    site_path = path.mkdir('_site')
+
+    # Set up a nanogen blog for posts
+    blog = models.Blog(path)
+    blog.init()
+
+    with mock.patch('subprocess.call'):
+        blog.new_post('Test title 1', draft=False)
+
+    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('[site]')
+
+    # Refresh the blog instance to better emulate real-world usage
+    blog = models.Blog(path)
+    blog.generate_index_page()
+
+    site_dir = [os.path.basename(file) for file in site_path.listdir()]
+    assert 'index.html' in site_dir
+
+
+def test_blog_generate_feeds_no_feed_files(tmpdir):
+    path = tmpdir.mkdir('blog')
+    site_path = path.mkdir('_site')
+
+    # Set up a nanogen blog for posts
+    blog = models.Blog(path)
+    blog.init()
+
+    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(path)
+    blog.generate_feeds()
+
+    site_dir = [os.path.basename(file) for file in site_path.listdir()]
+    assert 'rss.xml' not in site_dir
+    assert 'feed.json' not in site_dir
+
+
+def test_blog_feeds(tmpdir):
+    path = tmpdir.mkdir('blog')
+    site_path = path.mkdir('_site')
+
+    # Set up a nanogen blog for posts
+    blog = models.Blog(path)
+    blog.init()
+
+    with mock.patch('subprocess.call'):
+        blog.new_post('Test title 1', draft=False)
+
+    rss_template = path.join('_layout').join('rss.xml')
+    rss_template.write("""RSS Feed template would go here.""")
+
+    json_template = path.join('_layout').join('feed.json')
+    json_template.write("""JSON Feed template would go here.""")
+
+    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(path)
+    blog.generate_feeds()
+
+    site_dir = [os.path.basename(file) for file in site_path.listdir()]
+    assert 'rss.xml' in site_dir
+    assert 'feed.json' in site_dir
+
+
+def test_blog_build_and_clean(tmpdir):
+    def test_blog_generate_index_page(tmpdir):
+        path = tmpdir.mkdir('blog')
+        site_path = path.mkdir('_site')
+
+        # Set up a nanogen blog for posts
+        blog = models.Blog(path)
+        blog.init()
+
+        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(path)
+        blog.build()
+
+        site_dir = [os.path.basename(file) for file in site_path.listdir()]
+        assert 'index.html' in site_dir
+
+        today = datetime.date.today()
+        expected_post_dir = site_path.join(f'{today.year}').join(f'{today.month:02d}')
+        generated_posts = [os.path.basename(file) for file in expected_post_dir.listdir()]
+        assert len(generated_posts) == 1
+        assert 'test-title-1.html' in generated_posts
+
+        blog.clean()
+        assert not os.path.isdir(site_path)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..88c64a2
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,16 @@
+from nanogen import utils
+
+
+def test_slugify():
+    assert utils.slugify('This is a test') == 'this-is-a-test'
+    assert utils.slugify('this is ^&* a test') == 'this-is-----a-test'
+
+
+def test_is_valid_post_file():
+    assert not utils.is_valid_post_file('asldkjf')
+    assert not utils.is_valid_post_file('asldkjf.md')
+    assert not utils.is_valid_post_file('12-34-56-example.md')
+    assert not utils.is_valid_post_file('_2018-01-01-example-file.md')
+    assert not utils.is_valid_post_file('2018-01-01-example-file.html')
+    assert utils.is_valid_post_file('2018-01-01-example-file.md')
+

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
-e git+https://github.com/epochblue/nanogen.git@263ca7f785530adfafc3ce5afa496b6c3f465816#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
      - 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_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_utils.py::test_is_valid_post_file
tests/test_models.py::test_blog_build_and_clean tests/test_utils.py::test_slugify
nanogen/models.py setup.py .gitignore nanogen/cli.py nanogen/utils.py
nanogen/models.py setup.py .gitignore nanogen/cli.py nanogen/utils.py