diff --git a/.gitignore b/.gitignore index e5e248e..a6ac08c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist/ build/ *.egg-info/ node_modules/ +tests/dist/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e7118ca..0000000 --- a/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM python:slim-buster -LABEL org.opencontainers.image.source=https://github.com/jamiefdhurst/blog - -ENV FLASK_APP=blog -WORKDIR /app - -COPY . . -RUN python setup.py develop - -EXPOSE 5000 -ENTRYPOINT ["flask", "run", "-h", "0.0.0.0"] diff --git a/Dockerfile.test b/Dockerfile.test deleted file mode 100644 index d28f508..0000000 --- a/Dockerfile.test +++ /dev/null @@ -1,8 +0,0 @@ -FROM python:slim-buster -ENV FLASK_APP=blog -ENV FLASK_ENV=development -WORKDIR /app -COPY . . -RUN python setup.py develop -RUN pip install pylint pytest requests-mock coverage -EXPOSE 5000 diff --git a/Makefile b/Makefile deleted file mode 100644 index bdc9007..0000000 --- a/Makefile +++ /dev/null @@ -1,41 +0,0 @@ -PWD := $(shell echo `pwd`) -DOCKER_MAIN := docker run -v $(PWD):/app -e GITHUB_USERNAME=$(GITHUB_USERNAME) -e GITHUB_TOKEN=$(GITHUB_TOKEN) -DOCKER_IMAGE := --name blog -i blog -DOCKER_TEST = blog-test -DOCKER_TEST_IMAGE := --name blog-test -i $(DOCKER_TEST) - -## build: build the container image for the blog -build : - @docker build -t blog . - @docker build -t blog-test -f Dockerfile.test . - -test-background : - @docker ps | grep blog-test || $(DOCKER_MAIN) -d $(DOCKER_TEST_IMAGE) tail -f /dev/null - @echo "Container running in background as 'blog-test'..." - -test-clean : - @docker stop $(DOCKER_TEST) || true - @docker rm $(DOCKER_TEST) || true - -test : test-background - @docker exec $(DOCKER_TEST) coverage run -m pytest --verbose - -test-with-reports : test - @docker exec $(DOCKER_TEST) coverage run -m pytest --verbose --junit-xml tests.xml - @docker exec $(DOCKER_TEST) coverage xml -o coverage.xml - -run : - @$(DOCKER_MAIN) -p 5000:5000 $(DOCKER_IMAGE) - -clean : test-clean - @docker stop blog || true - @docker rm blog || true - -.PHONY: help -all: help -help: Makefile - @echo - @echo " Choose a command to run:" - @echo - @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' - @echo \ No newline at end of file diff --git a/README.md b/README.md index 1a331bc..7cf3933 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Blog -My blog website - built using Flask in Python. A standalone web app that -requires no separate database and is powered through Markdown articles. +My blog website - generated from Jinja templates and markdown articles as a +static site. ## Development @@ -11,10 +11,10 @@ You will require Python 3. Run the following to install required modules: python3 setup.py develop ``` -You can then launch the project using: +You can then generate the site using: ```bash -FLASK_APP=blog flask run +python3 -m blog.generate ``` ## Testing @@ -27,17 +27,8 @@ pytest --verbose ## Build and Release -The Jenkins pipeline handles build and release. Upon the main branch being -updated, a new version of the blog will eb automatically released to GitHub and -the new version will be immediately deployed to the live website. The same -pipeline can be used to deploy other versions if required. - -## Makefile - -A Makefile has been added for convenience to run certain commands: - -* `build` - build the Docker images (standard and test) -* `test` - run the tests after launching the test container -* `test-with-reports` - run the tests and output the required reports -* `run` - launch the blog container listening on port 5000 -* `clean` - shut down and clean all running containers +The GitHub Actions pipeline handles testing, building and releasing the static +site version. Upon the main branch being updated, a new version of the blog will +be automatically released to GitHub and the new version will be immediately +deployed to the live website. The same pipeline can be used to deploy other +versions if required. diff --git a/blog/__init__.py b/blog/__init__.py index 4ddcbce..e69de29 100644 --- a/blog/__init__.py +++ b/blog/__init__.py @@ -1,37 +0,0 @@ -import os -from flask import Flask, g, render_template, url_for -from werkzeug.exceptions import HTTPException - -def create_app(test_config=None): - app = Flask(__name__) - if test_config is None: - app.config.from_pyfile('config.py') - else: - app.config.from_mapping(test_config) - - try: - os.makedirs(app.instance_path) - except OSError: - pass - - @app.before_request - def load_version(): - # pylint: disable=assigning-non-slot - g.version = app.config['VERSION'] - - from . import blog - app.register_blueprint(blog.bp) - app.add_url_rule('/', endpoint='index') - - @app.errorhandler(Exception) - def handle_exception(err): - if isinstance(err, HTTPException): - return err - - return render_template('500.html', err=err), 500 - - @app.errorhandler(404) - def page_not_found(err): - return render_template('404.html', err=err), 404 - - return app diff --git a/blog/articles.py b/blog/articles.py index 639923c..c25cd0c 100644 --- a/blog/articles.py +++ b/blog/articles.py @@ -3,7 +3,6 @@ from os import listdir from os.path import isfile, join import re -from flask import current_app from markdown import markdown class Article: @@ -76,7 +75,6 @@ def get_paginated_articles(directory, page=1, per_page=10): files = [f for f in listdir(directory) if isfile(join(directory, f))] files.sort() files.reverse() - current_app.logger.debug(f"Returned {len(files)} files...") first_entry = (page - 1) * per_page last_entry = first_entry + per_page files = files[first_entry:last_entry] @@ -86,7 +84,6 @@ def get_all_articles(directory): files = [f for f in listdir(directory) if isfile(join(directory, f))] files.sort() files.reverse() - current_app.logger.debug(f"Returned {len(files)} files...") return __parse_articles(directory, files) def get_pages(directory, per_page=10): diff --git a/blog/blog.py b/blog/blog.py deleted file mode 100644 index ee6229f..0000000 --- a/blog/blog.py +++ /dev/null @@ -1,59 +0,0 @@ -from flask import ( - abort, Blueprint, current_app, make_response, render_template, request, send_from_directory -) - -from . import articles - -bp = Blueprint('blog', __name__) - -@bp.route('/favicon.ico') -@bp.route('/humans.txt') -@bp.route('/robots.txt') -def static_from_root(): - return send_from_directory(current_app.static_folder, request.path[1:]) - -@bp.route('/sitemap.xml', methods=['GET']) -def sitemap(): - directory = current_app.config['ARTICLES_DIR'] - parsed_articles = articles.get_all_articles(directory) - - template = render_template( - 'sitemap.xml', - articles=parsed_articles, - ) - response = make_response(template) - response.headers['Content-Type'] = 'application/xml' - - return response - -@bp.route('/', methods=['GET']) -def index(): - directory = current_app.config['ARTICLES_DIR'] - current_page = int(request.args.get('page') or 1) - parsed_articles = articles.get_paginated_articles(directory, current_page) - pages = articles.get_pages(directory) - - return render_template( - 'index.html', - articles=parsed_articles, - current_page=current_page, - pages=[None] * pages, - ) - -@bp.route('/now', methods=['GET']) -def now(): - return render_template( - 'now.html', - ) - -@bp.route('/', methods=['GET']) -def view(slug): - directory = current_app.config['ARTICLES_DIR'] - try: - article = articles.get_article(directory, slug) - except articles.ArticleException: - abort(404) - return render_template( - 'view.html', - article=article, - ) diff --git a/blog/config.py b/blog/config.py index 4958d7f..f80fcd4 100644 --- a/blog/config.py +++ b/blog/config.py @@ -1,5 +1,5 @@ from os import environ -SECRET_KEY = environ.get('SECRET_KEY', default='dev') ARTICLES_DIR = environ.get('ARTICLES_DIR', default='articles/') +DIST_DIR = environ.get('DIST_DIR', default='dist/') VERSION = 'v0.22.1' diff --git a/blog/generate.py b/blog/generate.py new file mode 100644 index 0000000..2d16467 --- /dev/null +++ b/blog/generate.py @@ -0,0 +1,71 @@ +import os +import shutil +import sys +from jinja2 import Environment, PackageLoader, select_autoescape +from .articles import * +from .config import * + +env = Environment( + loader=PackageLoader('blog'), + autoescape=select_autoescape() +) + +def render_template(file, **kwargs): + template = env.get_template(file) + return template.render(**kwargs, version=VERSION) + +def generate(articles_dir=ARTICLES_DIR, dist_dir=DIST_DIR): + + # Create/clear dist folder + if os.path.isdir(dist_dir): + print('[INFO] Clearing existing output dir...') + shutil.rmtree(dist_dir) + print('[INFO] Creating output dir...') + os.mkdir(dist_dir) + + # Get all articles and generate a page for each one + print('[INFO] Loading articles...') + items = get_all_articles(articles_dir) + print('[INFO] Loaded {} articles...'.format(len(items))) + for item in items: + print('[INFO] Rendering and writing {}...'.format(item.get_name())) + rendered = render_template('view.html', article=item) + with open(dist_dir + item.get_name() + '.html', 'w') as output_file: + output_file.write(rendered) + + # Generate sitemap + print('[INFO] Rendering and writing sitemap...') + rendered = render_template('sitemap.xml', articles=items) + with open(dist_dir + 'sitemap.xml', 'w') as output_file: + output_file.write(rendered) + + # Generate static pages (inc error pages) + for static_page in ['404', '500', 'now']: + print('[INFO] Rendering and writing {}...'.format(static_page)) + rendered = render_template(static_page + '.html') + with open(dist_dir + static_page + '.html', 'w') as output_file: + output_file.write(rendered) + + # Generate home page and paginated elements + pages = get_pages(articles_dir) + print('[INFO] Found {} pages of articles...'.format(pages)) + for p in range(1, pages + 1): + print('[INFO] Rendering and writing index page {}...'.format(p)) + paged_items = get_paginated_articles(articles_dir, p) + rendered = render_template( + 'index.html', + articles=paged_items, + current_page=p, + pages=[None] * pages + ) + with open(dist_dir + 'index.html' if p == 1 else dist_dir + 'index-{}.html'.format(p), 'w') as output_file: + output_file.write(rendered) + + # Copy static assets + print('[INFO] Copying static assets...') + shutil.copytree('blog/static', dist_dir + 'static') + + print('Blog {} generated and output to {}'.format(VERSION, dist_dir)) + +if __name__ == '__main__': + sys.exit(generate()) diff --git a/blog/templates/base.html b/blog/templates/base.html index 01a21fa..3aaf0fd 100644 --- a/blog/templates/base.html +++ b/blog/templates/base.html @@ -7,7 +7,7 @@ - + @@ -29,7 +29,7 @@ {% block content %}{% endblock %} - + diff --git a/setup.py b/setup.py index b9b4f2f..8a3269e 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ include_package_data=True, zip_safe=False, install_requires=[ - 'flask', + 'jinja2', 'markdown', ], ) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index be9207f..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -from blog import create_app - - -@pytest.fixture -def app(): - app = create_app({ - 'ARTICLES_DIR': 'tests/articles/', - 'TESTING': True, - 'VERSION': 'DEVELOPMENT', - }) - - yield app - - -@pytest.fixture -def client(app): - return app.test_client() - - -@pytest.fixture -def runner(app): - return app.test_cli_runner() diff --git a/tests/test_articles.py b/tests/test_articles.py index f465b04..18840b3 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -11,32 +11,27 @@ def test_article_failure(): err = e assert err is not None -def test_get_paginated_articles(app): - with app.app_context(): - sut = get_paginated_articles('tests/articles/') - assert len(sut) == 2 - assert sut[0].get_name() == '2022-01-02_test-2' - -def test_get_paginated_articles_multiple_pages(app): - with app.app_context(): - sut = get_paginated_articles('tests/articles/', 1, 1) - assert len(sut) == 1 - assert sut[0].get_name() == '2022-01-02_test-2' - -def test_get_paginated_articles_out_of_range(app): - with app.app_context(): - sut = get_paginated_articles('tests/articles/', 2, 10) - assert len(sut) == 0 - -def test_get_all_articles(app): - with app.app_context(): - sut = get_all_articles('tests/articles/') - assert len(sut) == 2 - assert sut[0].get_name() == '2022-01-02_test-2' - -def test_get_pages(app): - with app.app_context(): - assert get_pages('tests/articles/') == 1 +def test_get_paginated_articles(): + sut = get_paginated_articles('tests/articles/') + assert len(sut) == 2 + assert sut[0].get_name() == '2022-01-02_test-2' + +def test_get_paginated_articles_multiple_pages(): + sut = get_paginated_articles('tests/articles/', 1, 1) + assert len(sut) == 1 + assert sut[0].get_name() == '2022-01-02_test-2' + +def test_get_paginated_articles_out_of_range(): + sut = get_paginated_articles('tests/articles/', 2, 10) + assert len(sut) == 0 + +def test_get_all_articles(): + sut = get_all_articles('tests/articles/') + assert len(sut) == 2 + assert sut[0].get_name() == '2022-01-02_test-2' + +def test_get_pages(): + assert get_pages('tests/articles/') == 1 def test_article_get_contents(): sut = Article('tests/articles/', '2022-01-01_test-1.md') diff --git a/tests/test_blog.py b/tests/test_blog.py deleted file mode 100644 index d019c84..0000000 --- a/tests/test_blog.py +++ /dev/null @@ -1,21 +0,0 @@ -def test_index(client): - response = client.get('/') - assert b'Blog' in response.data - assert b'Test 1' in response.data - assert b'Test 2' in response.data - assert b'placeholder.png' in response.data - assert b'very little inside of it' in response.data - -def test_now(client): - response = client.get('/now') - assert b'What I\'m doing right now...' in response.data - assert b'Martin Underhill' in response.data - assert b'
  • ' in response.data - -def test_view(client): - response = client.get('/2022-01-01_test-1') - assert b'Test 1' in response.data - assert b'Test 2' not in response.data - assert b'placeholder.png' in response.data - assert b'They were just sucked into space.' in response.data - diff --git a/tests/test_factory.py b/tests/test_factory.py deleted file mode 100644 index 63cf084..0000000 --- a/tests/test_factory.py +++ /dev/null @@ -1,14 +0,0 @@ -from blog import create_app - -def test_config(): - assert not create_app().testing - app = create_app({ - 'ARTICLES_DIR': 'tests/articles/', - 'TESTING': True}) - assert app.testing - assert app.config['ARTICLES_DIR'] == 'tests/articles/' - -def test_404(client): - response = client.get('/not-found-path') - assert b'Page Not Found' in response.data - assert 404 == response.status_code diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..a813050 --- /dev/null +++ b/tests/test_generate.py @@ -0,0 +1,15 @@ +from blog.generate import * +import os + +def test_generate(): + generate(articles_dir='tests/articles/', dist_dir='tests/dist/') + assert os.path.exists('tests/dist/') + assert os.path.exists('tests/dist/404.html') + assert os.path.exists('tests/dist/500.html') + assert os.path.exists('tests/dist/index.html') + assert os.path.exists('tests/dist/now.html') + assert os.path.exists('tests/dist/sitemap.xml') + assert os.path.exists('tests/dist/static') + assert os.path.exists('tests/dist/static/css/default.min.css') + assert os.path.exists('tests/dist/2022-01-01_test-1.html') + assert os.path.exists('tests/dist/2022-01-02_test-2.html')