From 62d0aa159fc046a27bed47e337d787a08f4687d0 Mon Sep 17 00:00:00 2001 From: Sunaina Pai Date: Sat, 17 Mar 2018 14:45:21 +0530 Subject: Add makesite: A simple static site generator --- .coveragerc | 3 + .gitignore | 8 + .travis.yml | 19 ++ LICENSE.md | 22 ++ Makefile | 73 ++++++ README.md | 376 +++++++++++++++++++++++++++++ content/_index.html | 28 +++ content/about.html | 23 ++ content/blog/2018-01-01-proin-quam.md | 29 +++ content/blog/2018-01-03-sed-finibus.md | 29 +++ content/contact.html | 21 ++ content/news/2018-01-02-vivamus-purus.html | 30 +++ content/news/2018-01-04-mauris-tempor.html | 37 +++ layout/feed.xml | 12 + layout/item.html | 10 + layout/item.xml | 13 + layout/list.html | 5 + layout/page.html | 42 ++++ layout/post.html | 5 + makesite.py | 213 ++++++++++++++++ static/css/style.css | 136 +++++++++++ test/__init__.py | 0 test/path.py | 16 ++ test/test_content.py | 114 +++++++++ test/test_file_io.py | 39 +++ test/test_headers.py | 43 ++++ test/test_list.py | 46 ++++ test/test_main.py | 73 ++++++ test/test_pages.py | 63 +++++ test/test_path.py | 78 ++++++ test/test_render.py | 25 ++ 31 files changed, 1631 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100755 README.md create mode 100644 content/_index.html create mode 100644 content/about.html create mode 100644 content/blog/2018-01-01-proin-quam.md create mode 100644 content/blog/2018-01-03-sed-finibus.md create mode 100644 content/contact.html create mode 100644 content/news/2018-01-02-vivamus-purus.html create mode 100644 content/news/2018-01-04-mauris-tempor.html create mode 100644 layout/feed.xml create mode 100644 layout/item.html create mode 100644 layout/item.xml create mode 100644 layout/list.html create mode 100644 layout/page.html create mode 100644 layout/post.html create mode 100755 makesite.py create mode 100644 static/css/style.css create mode 100644 test/__init__.py create mode 100644 test/path.py create mode 100644 test/test_content.py create mode 100644 test/test_file_io.py create mode 100644 test/test_headers.py create mode 100644 test/test_list.py create mode 100644 test/test_main.py create mode 100644 test/test_pages.py create mode 100644 test/test_path.py create mode 100644 test/test_render.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4ed6f59 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[report] +exclude_lines = + if __name__ == '__main__': diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77cf371 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +_site +*.pyc +__pycache__ +.coverage +htmlcov +*.sw? +.DS_Store +venv diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ef70b91 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: python + +python: + - "2.7" + - "3.3" + - "3.4" + - "3.5" + - "3.6" + +install: + - pip install commonmark coverage coveralls + +script: + - python -m unittest discover -bv + - coverage run --branch --source=. -m unittest discover -bv + - coverage report -m + +after_success: + - coveralls diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..3129e86 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +The MIT License (MIT) +===================== +Copyright (c) 2018 Sunaina Pai + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c00a218 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +site: + ./makesite.py + +serve: site + cd _site && python -m SimpleHTTPServer 2> /dev/null || python3 -m http.server + +venv2: + virtualenv ~/.venv/makesite + echo . ~/.venv/makesite/bin/activate > venv + . ./venv && pip install commonmark coverage + +venv: FORCE + python3 -m venv ~/.venv/makesite + echo . ~/.venv/makesite/bin/activate > venv + . ./venv && pip install commonmark coverage + +test: FORCE + . ./venv && python -m unittest -bv + +coverage: + . ./venv && coverage run --branch --source=. -m unittest discover -bv; : + . ./venv && coverage report -m + . ./venv && coverage html + +clean: + find . -name "__pycache__" -exec rm -r {} + + find . -name "*.pyc" -exec rm {} + + rm -rf .coverage htmlcov + +REV = cat /tmp/rev.txt +example: + # + # Remove existing output directories. + rm -rf _site /tmp/_site + # + # Create params.json for makesite-demo. + echo '{ "base_path": "/makesite-demo", "site_url":' \ + '"https://tmug.github.io/makesite-demo" }' > params.json + # + # Generate the website. + . ./venv && ./makesite.py + rm params.json + # + # Get current commit ID. + git rev-parse --short HEAD > /tmp/rev.txt + # + # Write a README for makesite-demo repository. + echo makesite.py demo > _site/README.md + echo ================ >> _site/README.md + echo This is the HTML/CSS source of an example static >> _site/README.md + echo website auto-generated with [sunainapai/makesite][makesite] >> _site/README.md + echo "([$$($(REV))][commit])". >> _site/README.md + echo >> _site/README.md + echo Visit "" to >> _site/README.md + echo view the example website. >> _site/README.md + echo >> _site/README.md + echo [makesite]: https://github.com/sunainapai/makesite >> _site/README.md + echo [commit]: https://github.com/sunainapai/makesite/commit/$$($(COMMIT)) >> _site/README.md + echo [demo]: https://tmug.github.io/makesite-demo >> _site/README.md + # + # Publish makesite-demo. + mv _site /tmp + cd /tmp/_site && git init + cd /tmp/_site && git add . + cd /tmp/_site && git commit -m "Auto-generated with sunainapai/makesite - $$($(REV))" + cd /tmp/_site && git remote add origin https://github.com/tmug/makesite-demo.git + cd /tmp/_site && git log + cd /tmp/_site && git push -f origin master + +loc: + grep -vE '^[[:space:]]*#|^[[:space:]]*$$' makesite.py | wc -l + +FORCE: diff --git a/README.md b/README.md new file mode 100755 index 0000000..0f625a9 --- /dev/null +++ b/README.md @@ -0,0 +1,376 @@ +makesite.py +=========== +Take full control of your static website/blog generation by writing your +own simple, lightweight, and magic-free static site generator in +Python. That's right! Reinvent the wheel, fellas! + +[![View Source][SOURCE-BADGE]](makesite.py) +[![View Demo][DEMO-BADGE]](https://tmug.github.io/makesite-demo) +[![MIT License][LICENSE-BADGE]](LICENSE.md) + +[SOURCE-BADGE]: https://img.shields.io/badge/view-source-brightgreen.svg +[DEMO-BADGE]: https://img.shields.io/badge/view-demo-brightgreen.svg +[LICENSE-BADGE]: https://img.shields.io/badge/license-MIT-blue.svg + + +Contents +-------- +* [Introduction](#introduction) +* [But Why?](#but-why) +* [Get Started](#get-started) +* [The Code](#the-code) +* [Layout](#layout) +* [Content](#content) +* [Credits](#credits) +* [License](#license) +* [Support](#support) + + +Introduction +------------ +This repository contains the source code of an example website +containing two static blogs and a few static pages. The website can be +generated by running [makesite.py](makesite.py). The output looks like +[this](https://tmug.github.io/makesite-demo). That's it! + +So go ahead, fork this repository, replace the [content](content) with +your own, and generate your static website. It's that simple! + +You are [free](LICENSE.md) to copy, use, and modify this project for +your blog or website, so go ahead and fork this repository and make it +your own project. Change the [layout](layout) if you wish to, improve +the [stylesheet](static/css/style.css) to suit your taste, enhance +[makesite.py](makesite.py) if you need to, and develop your website/blog +just the way you want it. + + +But Why? +-------- +For fun and profit! Okay, maybe not for profit, but hopefully for fun. + +Have you used a popular static site generator like Jekyll to generate +your blog? I have too. It is simple and great. But then did you yearn +to use something even simpler to generate your blog? Do you like Python? +Perhaps the thought of writing your own static site generator crossed +your mind but you thought it would be too much work? If you answered +"yes" to these questions, then this project is for you. + +With this project, you are in full control. There is no hidden magic! +Everything is laid out in [makesite.py](makesite.py) as plain and simple +Python code. It is just 125 lines of code (excluding blank lines +and comments). It gets you off the ground pretty quickly. You can have a +decent website/blog generated within a few minutes and then you can +begin tinkering with the [source code](makesite.py), the +[layout](layout), and the [css](static/css/style.css) to customize the +look and feel of your website to your satisfaction. + + +Get Started +----------- +This section provides some quick steps to get you off the ground as +quickly as possible. + + 1. For a quick demo on your local system, just enter this command: + + make serve + + If you don't have `make` but have Python 3.x, enter this command: + + python3 makesite.py + cd _site + python3 -m http.server + + Note: In some environments, you may need to use `python` instead of + `python3` to invoke Python 3.x. + + If you only have Python 2.7, enter this command: + + python makesite.py + cd _site + python -m SimpleHTTPServer + + Then visit http://localhost:8000/. It should look like + [this](https://tmug.github.io/makesite-demo). + + Note: You can run [makesite.py](makesite.py) with Python 2.7 or + Python 3.x. + + 2. You may see a few `Cannot render Markdown` warning messages in the + output of the previous command. This is due to the fact that an + example [blog](content/blog) in this project has a few posts written + in Markdown. To render them correctly, install the `commonmark` + package with this command: + + pip install commonmark + + Then try the previous step again. + + 3. For an Internet-facing website, you would be hosting the static + website/blog on a hosting service and/or with a web server such as + Apache HTTP Server, Nginx, etc. You probably only need to generate + the static files and know where the static files are and move them + to your hosting location. + + If you have the `make` command, enter this command to generate your + website: + + make site + + If you don't have `make` but have `python3`, enter this command: + + python3 makesite.py + + Note: In some environments, you may need to use `python` instead of + `python3` to invoke Python 3.x. + + If you only have `python`, enter this command: + + python makesite.py + + The `_site` directory contains the entire generated website. The + content of this directory may be copied to your website hosting + location. + + +The Code +-------- +Now that you know how to generate the static website that comes with +this project, it is time to see what [makesite.py](makesite.py) does. +You probably don't really need to read the entire section. The source +code is pretty self-explanatory but just in case, you need a detailed +overview of what it does, here are the details: + + 1. The `main()` function is the starting point of website generation. + It calls the other functions necessary to get the website generation + done. + + 2. First it creates a fresh new `_site` directory from scratch. All + files in the [static directory](static) are copied to this + directory. Later the static website is generated and written to this + directory. + + 3. Then it creates a `params` dictionary with some default parameters. + This dictionary is passed around to other functions. These other + functions would pick values from this dictionary to populate + placeholders in the layout template files. + + Let us take the `subtitle` parameter for example. It is set + to our example website's fictitious brand name: "Lorem Ipsum". We + want each page to include this brand name as a suffix in the title. + For example, the [about page](https://tmug.github.io/makesite-demo/about/) + has "About - Lorem Ipsum" in its title. Now take a look at the + [page layout template](layout/page.html) that is used as the layout + for all pages in the static website. This layout file uses the + `{{ subtitle }}` syntax to denote that it is a placeholder that + should be populated while rendering the template. + + Another interesting thing to note is that a content file can + override these parameters by defining its own parameters in the + content header. For example, take a look at the content file for + the [home page](content/_index.html). In its content header, i.e., + the HTML comments at the top with key-value pairs, it defines a new + parameter named `title` and overrides the `subtitle` parameter. + + We will discuss the syntax for placeholders and content headers + later. It is quite simple. + + 4. It then loads all the layout templates. There are 6 of them in this + project. + + - [layout/page.html](layout/page.html): It contains the base + template that applies to all pages. It begins with + `` and ``, and ends with ``. The + `{{ content }}` placeholder in this template is replaced with + the actual content of the page. For example, for the about page, + the `{{ content }}` placeholder is replaced with the the entire + content from [content/about.html](content/about.html). This is + done with the `make_pages()` calls further down in the code. + + - [layout/post.html](layout/post.html): It contains the template + for the blog posts. Note that it does not begin with `` and does not contain the `` and `` tags. + This is not a complete standalone template. This template + defines only a small portion of the blog post pages that are + specific to blog posts. It contains the HTML code and the + placeholders to display the title, publication date, and author + of blog posts. + + This template must be combined with the + [page layout template](layout/page.html) to create the final + standalone template. To do so, we replace the `{{ content }}` + placeholder in the [page layout template](layout/page.html) with + the HTML code in the [post layout template](layout/post.html) to + get a final standalone template. This is done with the + `render()` calls further down in the code. + + The resulting standalone template still has a `{{ content }}` + placeholder from the [post layout template](layout/post.html) + template. This `{{ content }}` placeholder is then replaced + with the actual content from the [blog posts](content/blog). + + - [layout/list.html](layout/list.html): It contains the template + for the blog listing page, the page that lists all the posts in + a blog in reverse chronological order. This template does not do + much except provide a title at the top and an RSS link at the + bottom. The `{{ content }}` placeholder is populated with the + list of blog posts in reverse chronological order. + + Just like the [post layout template](layout/post.html) , this + template must be combined with the + [page layout template](layout/page.html) to arrive at the final + standalone template. + + - [layout/item.html](layout/item.html): It contains the template + for each blog post item in the blog listing page. The + `make_list()` function renders each blog post item with this + template and inserts them into the + [list layout template](layout/list.html) to create the blog + listing page. + + - [layout/feed.xml](layout/feed.xml): It contains the XML template + for RSS feeds. The `{{ content }}` placeholder is populated with + the list of feed items. + + - [layout/item.xml](layout/item.xml): It contains the XML template for + each blog post item to be included in the RSS feed. The + `make_list()` function renders each blog post item with this + template and inserts them into the + [layout/feed.xml](layout/feed.xml) template to create the + complete RSS feed. + + 5. After loading all the layout templates, it makes a `render()` call + to combine the [post layout template](layout/post.html) with the + [page layout template](layout/page.html) to form the final + standalone post template. + + Similarly, it combines the [list layout template](layout/list.html) + template with the [page layout template](layout/page.html) to form + the final list template. + + 6. Then it makes two `make_pages()` calls to render the home page and a + couple of other site pages: the [contact page](content/contact.html) + and the [about page](content/about.html). + + 7. Then it makes two more `make_pages()` calls to render two blogs: one + that is named simply [blog](content/blog) and another that is named + [news](content/news). + + Note that the `make_pages()` call accepts three positional + arguments: + + - Path to content source files provided as a glob pattern. + - Output path template as a string. + - Layout template code as a string. + + These three positional arguments are then followed by keyword + arguments. These keyword arguments are used as template parameters + in the output path template and the layout template to replace the + placeholders with their corresponding values. + + As described in point 2 above, a content file can override these + parameters in its content header. + + 8. Then it makes two `make_list()` calls to render the blog listing + pages for the two blogs. These calls are very similar to the + `make_pages()` calls. There are only two things that are different + about the `make_list()` calls: + + - There is no point in reading the same blog posts again that were + read by `make_pages()`, so instead of passing the path to + content source files, we feed a chronologically reverse-sorted + index of blog posts returned by `make_pages()` to `make_list()`. + - There is an additional argument to pass the + [item layout template](layout/item.html) as a string. + + 9. Finally it makes two more `make_list()` calls to generate the RSS + feeds for the two blogs. There is nothing different about these + calls than the previous ones except that we use the feed XML + templates here to generate RSS feeds. + +To recap quickly, we create a `_site` directory to write the static site +generated, define some default parameters, load all the layout +templates, and then call `make_pages()` to render pages and blog posts +with these templates, call `make_list()` to render blog listing pages +and RSS feeds. That's all! + +Take a look at how the `make_pages()` and `make_list()` functions are +implemented. They are very simple with less than 20 lines of code each. +Once you are comfortable with this code, you can begin modifying it to +add more blogs or reduce them. For example, you probably don't need a +news blog, so you may delete the `make_pages()` and `make_list()` calls +for `'news'` along with its content at [content/news](content/news). + + +Layout +------ +In this project, the layout template files are located in the [layout +directory](layout). But they don't necessarily have to be there. You can +place the layout files wherever you want and update +[makesite.py](makesite.py) accordingly. + +The source code of [makesite.py](makesite.py) that comes with this +project understands the notion of placeholders in the layout templates. +The template placeholders have the following syntax: + + {{ }} + +Any whitespace before `{{`, around ``, and after `}}` is ignored. +The `` should be a valid Python identifier. Here is an example of +template placeholder: + + {{ title }} + +This is a very simple template mechanism that is implemented already in +the [makesite.py](makesite.py). For a simple website or blog, this +should be sufficient. If you need a more sophisticated template engine +such as [Jinja2](http://jinja.pocoo.org/) or +[Cheetah](https://pythonhosted.org/Cheetah/), you need to modify +[makesite.py](makesite.py) to add support for it. + + +Content +------- +In this project, the content files are located in the [content +directory](content). Most of the content files are written in HTML. +However, the content files for the blog named [blog](content/blog) are +written in Markdown. + +The notion of headers in the content files is supported by +[makesite.py](makesite.py). Each content file may begin with one or more +consecutive HTML comments that contain headers. Each header has the +following syntax: + + + +Any whitespace before, after, and around the `` tokens are ignored. Here are some example headers: + + + + + +It looks for the headers at the top of every content file. As soon as +some non-header text is encountered, the rest of the content from that +point is not checked for headers. + + +Credits +------- +Thanks to [Susam](https://github.com/susam) for writing the +documentation and the unit tests. + + +License +------- +This is free and open source software. You can use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of it, +under the terms of the [MIT License](LICENSE.md). + +This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND, +express or implied. See the [MIT License](LICENSE.md) for details. + + +Support +------- +To report bugs, suggest improvements, or ask questions, please visit +. diff --git a/content/_index.html b/content/_index.html new file mode 100644 index 0000000..8cc489a --- /dev/null +++ b/content/_index.html @@ -0,0 +1,28 @@ + + +

Lorem Ipsum

+

+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc nibh +tellus, vehicula ut maximus sed, fringilla a justo. Nunc vitae efficitur +nisl. Ut sapien erat, pretium et commodo nec, rutrum bibendum magna. Sed +ut massa massa. Etiam euismod neque lacus, id tincidunt risus iaculis a. +Aliquam porta venenatis bibendum. Nam id varius nulla. Sed vitae purus +ac odio ornare vestibulum vitae tempor arcu. Nunc non venenatis purus. +Duis a augue at nulla congue egestas. Morbi mattis felis sit amet tortor +euismod, fringilla viverra est elementum. +

+

+Suspendisse vestibulum sed massa eu tincidunt. Pellentesque cursus, nisl +at gravida suscipit, odio quam placerat mi, in iaculis nunc risus eu +sapien. Suspendisse potenti. Nullam fermentum, tellus vel faucibus +dictum, arcu ante rutrum nisi, ut iaculis eros felis a lectus. Etiam vel +maximus nulla, sed mattis lectus. Aliquam commodo est massa, at +vestibulum diam commodo vehicula. Nullam et tristique tortor. Praesent +luctus, leo id mattis mattis, ex dui dapibus dolor, nec ultrices turpis +nibh in sem. In efficitur, velit ut bibendum interdum, libero turpis +mattis odio, non pharetra metus leo nec arcu. Maecenas auctor laoreet +maximus. Donec metus massa, scelerisque a lacinia et, viverra eget +metus. Aenean vitae tellus vehicula, mattis metus in, facilisis purus. +In purus erat, fringilla ut diam et, convallis convallis nunc. Morbi +sagittis interdum ipsum sit amet fringilla. +

diff --git a/content/about.html b/content/about.html new file mode 100644 index 0000000..f51d17d --- /dev/null +++ b/content/about.html @@ -0,0 +1,23 @@ + +

About

+

+Quisque quam nisl, egestas nec convallis vitae, fringilla nec mauris. +Sed et cursus lacus, a pharetra ex. Pellentesque rhoncus malesuada elit +at sodales. In ut elit lectus. Phasellus et hendrerit odio, ac hendrerit +ante. Pellentesque habitant morbi tristique senectus et netus et +malesuada fames ac turpis egestas. Quisque sem nibh, auctor vel dictum +eu, pharetra sit amet nunc. Integer suscipit suscipit dapibus. +Suspendisse vulputate sed mauris eget tempus. Etiam rhoncus, leo nec +cursus elementum, massa lorem fermentum nisi, non convallis nisl dolor +vel ipsum. +

+

+Aliquam imperdiet vel purus sed facilisis. Mauris condimentum vel nulla +ac tempor. In non venenatis arcu. Nam in sapien purus. Suspendisse +faucibus, erat et fringilla vestibulum, ligula nisi porta odio, ut +tristique dui ante eu nisi. Mauris vitae vulputate lorem. Proin tortor +nisl, vehicula sed justo sed, volutpat bibendum purus. Phasellus luctus +fringilla augue ac sodales. Aenean ac nisi sit amet neque pulvinar +tincidunt ut nec ipsum. Aliquam purus tellus, dignissim a augue +placerat, aliquet semper turpis. Fusce id lacinia quam, vel porta quam. +

diff --git a/content/blog/2018-01-01-proin-quam.md b/content/blog/2018-01-01-proin-quam.md new file mode 100644 index 0000000..540a467 --- /dev/null +++ b/content/blog/2018-01-01-proin-quam.md @@ -0,0 +1,29 @@ + +Proin quam urna, pulvinar id ipsum ac, mattis consectetur ante. Praesent +non justo lectus. Duis egestas arcu libero, quis laoreet dolor volutpat +ut. Donec facilisis orci sit amet sem blandit elementum. Vestibulum +suscipit consectetur diam, ac posuere metus condimentum in. Integer +vehicula vitae enim id gravida. Vestibulum ut eros vitae risus porttitor +porta in eget felis. Nulla lorem erat, mattis eget lacus eget, interdum +aliquet lectus. Fusce non felis diam. Mauris sagittis porttitor est et +vestibulum. Duis faucibus commodo est. Maecenas elit purus, auctor a +consectetur eu, suscipit nec metus. Nam gravida id massa quis faucibus. +Sed non consectetur eros. Nullam iaculis sit amet ex eget ultrices. Sed +ligula arcu, vehicula vel ipsum nec, ultrices pulvinar ante. + +Vivamus egestas justo sed nulla condimentum iaculis. Pellentesque +eleifend elementum turpis sed tempus. Aliquam erat volutpat. In hac +habitasse platea dictumst. Integer elementum sed diam at vulputate. +Donec maximus, lacus a vulputate sagittis, felis turpis vestibulum +massa, nec tincidunt libero felis eget arcu. Morbi eget velit vulputate, +pellentesque odio laoreet, consequat orci. Aliquam erat volutpat. Nulla +a vulputate mi, sed laoreet ipsum. Nulla nunc ipsum, ultricies at +suscipit id, consectetur id erat. Suspendisse scelerisque vehicula felis +quis luctus. + +Donec posuere ante a nibh dictum suscipit. Mauris interdum dolor nulla, +sit amet fermentum quam porta a. Ut metus ipsum, venenatis a lorem +pellentesque, finibus commodo turpis. Cras mollis dui quis varius +placerat. Phasellus nec nisi nec quam tincidunt luctus. Sed nec +vulputate enim, vel dapibus enim. Praesent mollis maximus enim in +tempus. Phasellus a arcu lorem. Sed dictum rhoncus tempus. diff --git a/content/blog/2018-01-03-sed-finibus.md b/content/blog/2018-01-03-sed-finibus.md new file mode 100644 index 0000000..783129c --- /dev/null +++ b/content/blog/2018-01-03-sed-finibus.md @@ -0,0 +1,29 @@ + +Sed finibus fermentum convallis. Sed consequat, lacus a pellentesque +suscipit, lorem libero egestas dui, sit amet volutpat mi dolor sit amet +nisl. Maecenas faucibus iaculis nibh id gravida. Aenean ac nulla +efficitur, pulvinar massa eget, viverra enim. Ut vulputate velit nisl, +id volutpat nibh rhoncus a. Phasellus pretium finibus tempus. +Suspendisse placerat nibh sem, sit amet vehicula urna porta quis. +Suspendisse ac nisi maximus, porttitor sem id, dignissim ipsum. Sed +suscipit dolor sed velit viverra, quis viverra dui placerat. Vestibulum +dapibus molestie dapibus. + +Praesent fringilla dapibus enim quis consectetur. Donec eget nibh nisl. +Proin venenatis interdum nunc, sed venenatis orci suscipit in. Morbi sed +lacinia tellus, iaculis condimentum tellus. Pellentesque habitant morbi +tristique senectus et netus et malesuada fames ac turpis egestas. +Maecenas sem mi, commodo ut facilisis pharetra, aliquam id nisi. Morbi +nec nisi sed tortor faucibus pretium nec non eros. Nullam rhoncus +pharetra elementum. Ut tincidunt fermentum metus, at fermentum mi +scelerisque id. Aenean sed odio auctor, placerat velit sed, consectetur +felis. Donec volutpat id lorem non ornare. Phasellus convallis mi magna, +vel laoreet ex efficitur fermentum. + +Phasellus a interdum odio, vitae finibus leo. Pellentesque porta quis +massa non suscipit. Mauris finibus vel nibh quis scelerisque. +Suspendisse eget molestie lacus, eu mattis felis. Proin ex tellus, +ultrices eu facilisis vel, faucibus eget enim. Integer sit amet magna +ligula. Ut massa nisl, sodales vel eros ac, dignissim maximus metus. +Donec odio nulla, sollicitudin quis dolor in, varius sodales lectus. Sed +non lacinia ligula, eu pulvinar eros. diff --git a/content/contact.html b/content/contact.html new file mode 100644 index 0000000..f868ad9 --- /dev/null +++ b/content/contact.html @@ -0,0 +1,21 @@ + +

Contact

+

+In hac habitasse platea dictumst. Suspendisse purus leo, laoreet ac +scelerisque vitae, gravida vitae turpis. Etiam lacinia justo in pharetra +tincidunt. Donec id mi in elit euismod feugiat. Fusce eget velit nec +nunc fermentum ultrices ut auctor tellus. Suspendisse convallis lacus a +mollis volutpat. Donec maximus eros lorem, non faucibus sapien tristique +a. Proin ut magna eget nunc sagittis sodales ac suscipit dolor. +Vestibulum sit amet velit nunc. Nam euismod fermentum neque ac +facilisis. Phasellus imperdiet arcu a lorem pulvinar accumsan. Sed +maximus neque tristique, sollicitudin risus sed, interdum enim. +

+

+Curabitur vel augue mattis, blandit libero rhoncus, fringilla augue. +Aenean condimentum ex justo. In hac habitasse platea dictumst. Etiam +ullamcorper finibus enim, nec cursus dui tristique nec. Phasellus et +tortor libero. Vivamus viverra euismod pulvinar. Fusce maximus, ante +quis lobortis facilisis, lectus mi consequat purus, sed vestibulum ipsum +mi sit amet dui. +

diff --git a/content/news/2018-01-02-vivamus-purus.html b/content/news/2018-01-02-vivamus-purus.html new file mode 100644 index 0000000..42845b1 --- /dev/null +++ b/content/news/2018-01-02-vivamus-purus.html @@ -0,0 +1,30 @@ + +

+Vivamus purus tellus, facilisis in sapien quis, ullamcorper lacinia +neque. Morbi tincidunt ac leo sit amet auctor. Donec dolor mauris, +lobortis eget faucibus sit amet, egestas non ante. Sed quam erat, +consectetur eget magna in, pretium aliquet ligula. Proin id nunc ex. +Curabitur posuere lectus neque, ut blandit diam scelerisque sed. In +semper lacinia ipsum a malesuada. Nam accumsan consequat sem, eu mollis +diam iaculis commodo. +

+

+Fusce arcu turpis, blandit lacinia augue et, dignissim imperdiet lorem. +Donec fermentum dui eu fermentum accumsan. Nunc porta tellus sit amet +nulla tempor varius. Ut id dolor velit. Aenean et dolor ac nisl mattis +iaculis. Phasellus at convallis lectus. Curabitur volutpat purus sed +purus feugiat, in elementum orci ullamcorper. Nam sollicitudin pretium +lacus, quis convallis massa suscipit et. Fusce ac elit tristique, +efficitur est at, pellentesque tellus. +

+

+Suspendisse vel eleifend nunc. Vivamus ac sem luctus, luctus velit ac, +pretium urna. Sed eu diam quis odio euismod sollicitudin. Ut faucibus +pharetra sem, ut malesuada sapien maximus at. Quisque et eleifend augue. +Sed dignissim urna ac justo venenatis vestibulum. Vivamus sodales, dui +sed luctus porttitor, ante purus pretium velit, at dapibus enim felis a +tortor. Fusce sit amet ipsum odio. Etiam eget felis sed risus efficitur +rutrum at a libero. Praesent finibus pellentesque tellus sed maximus. +Morbi sit amet turpis eros. Vivamus rhoncus libero ut lacinia luctus. Ut +nec ex dui. +

diff --git a/content/news/2018-01-04-mauris-tempor.html b/content/news/2018-01-04-mauris-tempor.html new file mode 100644 index 0000000..b7dd769 --- /dev/null +++ b/content/news/2018-01-04-mauris-tempor.html @@ -0,0 +1,37 @@ + +

+Mauris tempor nulla odio, vel tempus metus bibendum sit amet. Vivamus +consequat fringilla tristique. Nullam a congue ligula, eget rutrum +lectus. In pulvinar at nunc ac finibus. Sed cursus consequat sem et +egestas. Cras eu eros vel mi eleifend efficitur eu dapibus orci. +Phasellus non bibendum purus. Nam facilisis laoreet massa tempus +vehicula. Donec a hendrerit nisi. +

+

+Vestibulum blandit dui a lorem pulvinar, et auctor eros elementum. +Vestibulum tortor ex, pharetra id metus vel, tincidunt faucibus nisl. +Suspendisse potenti. Nam maximus est mi, sit amet tristique nisi pretium +sed. Maecenas in nulla at nibh volutpat lacinia. Aliquam erat volutpat. +Donec odio magna, imperdiet quis risus at, ultricies faucibus lorem. +Quisque tempus enim purus. Integer interdum mauris lorem, in accumsan +lorem tempor quis. Praesent facilisis, velit vitae aliquam mattis, +turpis justo malesuada odio, nec hendrerit tortor sapien sit amet diam. +Praesent aliquam malesuada feugiat. Proin vitae efficitur massa. +Vestibulum quam enim, finibus et magna ac, pellentesque aliquet leo. +Nullam condimentum neque nec ex viverra, sit amet ullamcorper lectus +sodales. Suspendisse non est et velit malesuada ullamcorper maximus +porta quam. +

+

+Cras ornare maximus augue nec varius. Aliquam tincidunt sodales ipsum. +Sed viverra ut nibh sit amet porta. Cras sit amet condimentum est. +Suspendisse quis tristique ipsum. Etiam vel lorem bibendum, laoreet dui +vitae, rutrum leo. Nullam cursus dui a augue lobortis euismod. +Suspendisse finibus id neque ut imperdiet. Pellentesque vel suscipit +est, vel vulputate neque. Curabitur eu eros auctor, laoreet nunc quis, +laoreet erat. Fusce rhoncus mattis lorem, et imperdiet nisi lacinia sit +amet. Integer et cursus lacus. Nullam id arcu et libero condimentum +tristique. Pellentesque arcu magna, aliquam sed lectus in, tempor mattis +erat. Curabitur vel massa sit amet nunc suscipit pulvinar. In tincidunt +diam metus, eu sollicitudin velit commodo a. +

diff --git a/layout/feed.xml b/layout/feed.xml new file mode 100644 index 0000000..388c39e --- /dev/null +++ b/layout/feed.xml @@ -0,0 +1,12 @@ + + + + +{{ title }} +{{ site_url }}/ +Grow with Technology + +{{ content }} + + + diff --git a/layout/item.html b/layout/item.html new file mode 100644 index 0000000..b16a097 --- /dev/null +++ b/layout/item.html @@ -0,0 +1,10 @@ + diff --git a/layout/item.xml b/layout/item.xml new file mode 100644 index 0000000..65b9679 --- /dev/null +++ b/layout/item.xml @@ -0,0 +1,13 @@ + +{{ title }} +{{ site_url }}/{{ blog }}/{{ slug }}/ + + +{{ summary }} ... +

+

Read More

+]]> +
+{{ date }} +
diff --git a/layout/list.html b/layout/list.html new file mode 100644 index 0000000..79a6aab --- /dev/null +++ b/layout/list.html @@ -0,0 +1,5 @@ +

{{ title }}

+{{ content }} +
+RSS +
diff --git a/layout/page.html b/layout/page.html new file mode 100644 index 0000000..16482e1 --- /dev/null +++ b/layout/page.html @@ -0,0 +1,42 @@ + + + + {{ title }} - {{ subtitle }} + + + + + + + + + +
+{{ content }} +
+ + + + + diff --git a/layout/post.html b/layout/post.html new file mode 100644 index 0000000..f0a42be --- /dev/null +++ b/layout/post.html @@ -0,0 +1,5 @@ +
+

{{ title }}

+

Published on {{ date }} by {{ author }}

+{{ content }} +
diff --git a/makesite.py b/makesite.py new file mode 100755 index 0000000..e0caa0f --- /dev/null +++ b/makesite.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python + +# The MIT License (MIT) +# +# Copyright (c) 2018 Sunaina Pai +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +"""Make static website/blog with Python.""" + + +import os +import shutil +import re +import glob +import sys +import json + + +def fread(filename): + """Read file and close the file.""" + with open(filename, 'r') as f: + return f.read() + + +def fwrite(filename, text): + """Write content to file and close the file.""" + basedir = os.path.dirname(filename) + if not os.path.isdir(basedir): + os.makedirs(basedir) + + with open(filename, 'w') as f: + f.write(text) + + +def log(msg, *args): + """Log message with specified arguments.""" + sys.stderr.write(msg.format(*args) + '\n') + + +def truncate(text, words=25): + return ' '.join(re.sub('(?s)<.*?>', ' ', text).split()[:words]) + + +def read_headers(text): + """Parse headers in text and yield (key, value, end-index) tuples.""" + for match in re.finditer('\s*\s*|.+', text): + if not match.group(1): + break + yield match.group(1), match.group(2), match.end() + + +def read_content(filename): + """Read content and metadata from file into a dictionary.""" + # Read file content. + text = fread(filename) + + # Read metadata. + date_slug = os.path.basename(filename).split('.')[0] + match = re.search('^(?:(\d\d\d\d-\d\d-\d\d)-)?(.+)$', date_slug) + content = { + 'date': match.group(1) or '1970-01-01', + 'slug': match.group(2), + } + + # Read headers. + end = 0 + for key, val, end in read_headers(text): + content[key] = val + + # Separate content from headers. + text = text[end:] + + # Convert Markdown content to HTML. + if filename.endswith(('.md', '.mkd', '.mkdn', '.mdown', '.markdown')): + try: + if _test == 'ImportError': + raise ImportError('Error forced by test') + import CommonMark + text = CommonMark.commonmark(text) + except ImportError as e: + log('WARNING: Cannot render Markdown in {}: {}', filename, str(e)) + + content.update({ + 'content': text, + 'summary': truncate(text), + }) + + return content + + +def render(template, **params): + """Replace placeholders in template with values from params.""" + for key, val in params.items(): + template = re.sub(r'{{\s*' + key + '\s*}}', str(val), template) + return template + + +def make_pages(src, dst, layout, **params): + """Generate pages from page content.""" + items = [] + + for src_path in glob.glob(src): + content = read_content(src_path) + items.append(content) + + params.update(content) + + dst_path = render(dst, **params) + output = render(layout, **params) + + log('Rendering {} => {} ...', src_path, dst_path) + fwrite(dst_path, output) + + return sorted(items, key=lambda x: x['date'], reverse=True) + + +def make_list(posts, dst, list_layout, item_layout, **params): + """Generate list page for a blog.""" + items = [] + for post in posts: + item_params = dict(params, **post) + item = render(item_layout, **item_params) + items.append(item) + + params['content'] = ''.join(items) + dst_path = render(dst, **params) + output = render(list_layout, **params) + + log('Rendering list => {} ...', dst_path) + fwrite(dst_path, output) + + +def main(): + # Create a new _site directory from scratch. + if os.path.isdir('_site'): + shutil.rmtree('_site') + shutil.copytree('static', '_site') + + # Default parameters. + params = { + 'base_path': '', + 'subtitle': 'Lorem Ipsum', + 'author': 'Admin', + 'site_url': 'http://localhost:8000', + } + + # If params.json exists, load it. + if os.path.isfile('params.json'): + params.update(json.loads(fread('params.json'))) + + # Load layouts. + page_layout = fread('layout/page.html') + post_layout = fread('layout/post.html') + list_layout = fread('layout/list.html') + item_layout = fread('layout/item.html') + feed_xml = fread('layout/feed.xml') + item_xml = fread('layout/item.xml') + + # Combine layouts to form final layouts. + post_layout = render(page_layout, content=post_layout) + list_layout = render(page_layout, content=list_layout) + + # Create site pages. + make_pages('content/_index.html', '_site/index.html', + page_layout, **params) + make_pages('content/[!_]*.html', '_site/{{ slug }}/index.html', + page_layout, **params) + + # Create blogs. + blog_posts = make_pages('content/blog/*.md', + '_site/blog/{{ slug }}/index.html', + post_layout, blog='blog', **params) + news_posts = make_pages('content/news/*.html', + '_site/news/{{ slug }}/index.html', + post_layout, blog='news', **params) + + # Create blog list pages. + make_list(blog_posts, '_site/blog/index.html', + list_layout, item_layout, blog='blog', title='Blog', **params) + make_list(news_posts, '_site/news/index.html', + list_layout, item_layout, blog='news', title='News', **params) + + make_list(blog_posts, '_site/blog/rss.xml', + feed_xml, item_xml, blog='blog', title='Blog', **params) + make_list(news_posts, '_site/news/rss.xml', + feed_xml, item_xml, blog='news', title='News', **params) + + +# Test parameter to be set temporarily by unit tests. +_test = None + + +if __name__ == '__main__': + main() diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b87bbe8 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,136 @@ +/* General */ +body { + margin: 0; + line-height: 1.5em; + color: #333333; + font-family: helvetica, arial, sans-serif; +} + +h1, h2, h3, h4, h5, h6 { + margin-bottom: 0; + line-height: 1.2em; + margin-top: 1em; +} + +a:link, a:visited { + color: #0000e0; + text-decoration: none +} + +a:hover, a:active { + color: #0000ff; + text-decoration: underline +} + + +/* Single column layout */ +nav section, header section, main, footer section { + max-width: 40em; + margin-left: auto; + margin-right: auto; + padding: 0 0.5em; +} + + +/* Navigation panel */ +nav { + background: #333333; + border-bottom: thin solid #111111; + line-height: 3em; +} + +nav a { + margin-right: 1em; +} + +nav a:link, nav a:visited { + color: #cccccc; +} + +nav a:hover, nav a:active { + color: #ffffff; + text-decoration: none; +} + +@media screen and (min-width: 600px) { + nav .links { + float: right; + } +} + + +/* Footer */ +footer { + background: #f0f0f0; + border-top: thin solid #e0e0e0; + margin-top: 2em; + padding: 1em 0; + box-shadow: 0 100vh 0 100vh #f0f0f0; + text-align: center; +} + +footer section p { + font-size: 0.8em; +} + +footer a { + margin-right: 1em; +} + + +/* Posts */ +article { + margin-top: 1em; + margin-bottom: 1em; +} + +article h2 a:link, article h2 a:visited, +article h1 a:link, article h1 a:visited, +.more:link, .more:visited { + color: #333333; +} + +article h2 a:hover, article h2 a:active, +article h1 a:hover, article h1 a:active, +.more:hover, .more:active { + color: #777777; +} + +p.meta { + margin-top: 0; + font-size: 0.8em; + color: #777777; + font-style: italic; +} + +p.summary { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +div .more { + margin-top: 0; + font-weight: bold; +} + + +/* RSS */ +.rss { + padding: 0.3em 0.35em; + border-radius: 0.25em; + font-size: 0.75em; + font-weight: bold; +} + +.rss:link, .rss:visited, .rss:hover, .rss:active { + color: #ffffff; + text-decoration: none; +} + +.rss:link, .rss:visited { + background: #ff6600; +} + +.rss:hover, .rss:active { + background: #ff8822; +} diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/path.py b/test/path.py new file mode 100644 index 0000000..38c0991 --- /dev/null +++ b/test/path.py @@ -0,0 +1,16 @@ +import os +import tempfile +import shutil + + +def temppath(*paths): + return os.path.join(tempfile.gettempdir(), *paths) + + +def move(src, dst): + if os.path.isfile(dst): + os.remove(dst) + elif os.path.isdir(dst): + shutil.rmtree(dst) + if os.path.exists(src): + os.rename(src, dst) diff --git a/test/test_content.py b/test/test_content.py new file mode 100644 index 0000000..06ec97d --- /dev/null +++ b/test/test_content.py @@ -0,0 +1,114 @@ +import unittest +import shutil +import os + +import makesite +from test import path + + +class ContentTest(unittest.TestCase): + def setUp(self): + self.blog_path = path.temppath('blog') + self.undated_path = os.path.join(self.blog_path, 'foo.txt') + self.dated_path = os.path.join(self.blog_path, '2018-01-01-foo.txt') + self.long_post_path = os.path.join(self.blog_path, 'bar.txt') + self.normal_post_path = os.path.join(self.blog_path, 'baz.txt') + self.md_post_path = os.path.join(self.blog_path, 'qux.md') + self.no_md_post_path = os.path.join(self.blog_path, 'qux.txt') + + os.makedirs(self.blog_path) + + with open(self.undated_path, 'w') as f: + f.write('hello world') + + with open(self.dated_path, 'w') as f: + f.write('hello world') + + with open(self.long_post_path, 'w') as f: + self.long_text = ' \n'.join('word' + str(i) for i in range(50)) + f.write(self.long_text) + + with open(self.normal_post_path, 'w') as f: + f.write('\n\nFoo') + + with open(self.md_post_path, 'w') as f: + f.write('*Foo*') + + with open(self.no_md_post_path, 'w') as f: + f.write('*Foo*') + + def tearDown(self): + shutil.rmtree(self.blog_path) + + # Rudimentary mock because unittest.mock is unavailable in Python 2.7. + def mock(self, *args): + self.mock_args = args + + def test_content_content(self): + content = makesite.read_content(self.long_post_path) + self.assertEqual(content['content'], self.long_text) + + def test_content_summary(self): + content = makesite.read_content(self.long_post_path) + expected_text = ' '.join('word' + str(i) for i in range(25)) + self.assertEqual(content['summary'], expected_text) + + def test_content_date(self): + content = makesite.read_content(self.dated_path) + self.assertEqual(content['date'], '2018-01-01') + + def test_content_date_missing(self): + content = makesite.read_content(self.undated_path) + self.assertEqual(content['date'], '1970-01-01') + + def test_content_slug_dated(self): + content = makesite.read_content(self.dated_path) + self.assertEqual(content['slug'], 'foo') + + def test_content_slug_undated(self): + content = makesite.read_content(self.undated_path) + self.assertEqual(content['slug'], 'foo') + + def test_content_headers(self): + content = makesite.read_content(self.normal_post_path) + self.assertEqual(content['a'], '1') + self.assertEqual(content['b'], '2') + self.assertEqual(content['content'], 'Foo') + + def test_markdown_rendering(self): + content = makesite.read_content(self.md_post_path) + self.assertEqual(content['content'], '

Foo

\n') + + def test_markdown_import_error(self): + makesite._test = 'ImportError' + original_log = makesite.log + + makesite.log = self.mock + self.mock_args = None + content = makesite.read_content(self.md_post_path) + + makesite._test = None + makesite.log = original_log + + self.assertEqual(content['content'], '*Foo*') + self.assertEqual(self.mock_args, + ('WARNING: Cannot render Markdown in {}: {}', + self.md_post_path, 'Error forced by test')) + + def test_no_markdown_rendering(self): + content = makesite.read_content(self.no_md_post_path) + self.assertEqual(content['content'], '*Foo*') + + def test_no_markdown_import_error(self): + makesite._test = 'ImportError' + original_log = makesite.log + + makesite.log = self.mock + self.mock_args = None + content = makesite.read_content(self.no_md_post_path) + + makesite._test = None + makesite.log = original_log + + self.assertEqual(content['content'], '*Foo*') + self.assertIsNone(self.mock_args) diff --git a/test/test_file_io.py b/test/test_file_io.py new file mode 100644 index 0000000..3760956 --- /dev/null +++ b/test/test_file_io.py @@ -0,0 +1,39 @@ +import unittest +import os +import shutil + +import makesite +from test import path + + +class FileIOTest(unittest.TestCase): + """Tests for file I/O functions.""" + + def test_fread(self): + text = 'foo\nbar\n' + filepath = path.temppath('foo.txt') + with open(filepath, 'w') as f: + f.write(text) + text_read = makesite.fread(filepath) + os.remove(filepath) + self.assertEqual(text_read, text) + + def test_fwrite(self): + text = 'baz\nqux\n' + filepath = path.temppath('foo.txt') + makesite.fwrite(filepath, text) + with open(filepath) as f: + text_read = f.read() + os.remove(filepath) + self.assertEqual(text_read, text) + + def test_fwrite_makedir(self): + text = 'baz\nqux\n' + dirpath = path.temppath('foo', 'bar') + filepath = os.path.join(dirpath, 'foo.txt') + makesite.fwrite(filepath, text) + with open(filepath) as f: + text_read = f.read() + self.assertTrue(os.path.isdir(dirpath)) + shutil.rmtree(path.temppath('foo')) + self.assertEqual(text_read, text) diff --git a/test/test_headers.py b/test/test_headers.py new file mode 100644 index 0000000..56eeb4a --- /dev/null +++ b/test/test_headers.py @@ -0,0 +1,43 @@ +import unittest +import makesite + + +class HeaderTest(unittest.TestCase): + """Tests for read_headers() function.""" + + def test_single_header(self): + text = '' + headers = list(makesite.read_headers(text)) + self.assertEqual(headers, [('key1', 'val1', 19)]) + + def test_multiple_headers(self): + text = '\n' + headers = list(makesite.read_headers(text)) + self.assertEqual(headers, [('key1', 'val1', 20), ('key2', 'val2', 38)]) + + def test_headers_and_text(self): + text = '\n\nFoo\n' + headers = list(makesite.read_headers(text)) + self.assertEqual(headers, [('a', '1', 14), ('b', '2', 28)]) + + def test_headers_and_blank_line(self): + text = '\n\n\n\n' + headers = list(makesite.read_headers(text)) + self.assertEqual(headers, [('a', '1', 14), + ('b', '2', 29), + ('c', '3', 43)]) + + def test_multiline_header(self): + text = '\n' + headers = list(makesite.read_headers(text)) + self.assertEqual(headers, [('a', '1', 13), + ('b', '2', 27), + ('c', '3', 40)]) + + def test_no_header(self): + headers = list(makesite.read_headers('Foo')) + self.assertEqual(headers, []) + + def test_empty_string(self): + headers = list(makesite.read_headers('')) + self.assertEqual(headers, []) diff --git a/test/test_list.py b/test/test_list.py new file mode 100644 index 0000000..8acb2ef --- /dev/null +++ b/test/test_list.py @@ -0,0 +1,46 @@ +import unittest +import shutil +import os +import makesite +from test import path + +class PagesTest(unittest.TestCase): + def setUp(self): + self.site_path = path.temppath('site') + + def tearDown(self): + shutil.rmtree(self.site_path) + + def test_list(self): + posts = [{'content': 'Foo'}, {'content': 'Bar'}] + dst = os.path.join(self.site_path, 'list.txt') + list_layout = '
{{ content }}
' + item_layout = '

{{ content }}

' + makesite.make_list(posts, dst, list_layout, item_layout) + with open(os.path.join(self.site_path, 'list.txt')) as f: + self.assertEqual(f.read(), '

Foo

Bar

') + + def test_list_params(self): + posts = [{'content': 'Foo', 'title': 'foo'}, + {'content': 'Bar', 'title': 'bar'}] + dst = os.path.join(self.site_path, 'list.txt') + list_layout = '
{{ key }}:{{ title }}:{{ content }}
' + item_layout = '

{{ key }}:{{ title }}:{{ content }}

' + makesite.make_list(posts, dst, list_layout, item_layout, + key='val', title='lorem') + with open(os.path.join(self.site_path, 'list.txt')) as f: + text = f.read() + self.assertEqual(text, + '
val:lorem:

val:foo:Foo

val:bar:Bar

') + + def test_dst_params(self): + posts = [{'content': 'Foo'}, {'content': 'Bar'}] + dst = os.path.join(self.site_path, '{{ key }}.txt') + list_layout = '
{{ content }}
' + item_layout = '

{{ content }}

' + makesite.make_list(posts, dst, list_layout, item_layout, key='val') + + expected_path = os.path.join(self.site_path, 'val.txt') + self.assertTrue(os.path.isfile(expected_path)) + with open(expected_path) as f: + self.assertEqual(f.read(), '

Foo

Bar

') diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..50f1cc5 --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,73 @@ +import unittest +import makesite +import os +import shutil +import json + +from test import path + + +class MainTest(unittest.TestCase): + def setUp(self): + path.move('_site', '_site.backup') + path.move('params.json', 'params.json.backup') + + def tearDown(self): + path.move('_site.backup', '_site') + path.move('params.json.backup', 'params') + + def test_site_missing(self): + makesite.main() + + def test_site_exists(self): + os.mkdir('_site') + with open('_site/foo.txt', 'w') as f: + f.write('foo') + + self.assertTrue(os.path.isfile('_site/foo.txt')) + makesite.main() + self.assertFalse(os.path.isfile('_site/foo.txt')) + + def test_default_params(self): + makesite.main() + + with open('_site/blog/proin-quam/index.html') as f: + s1 = f.read() + + with open('_site/blog/rss.xml') as f: + s2 = f.read() + + shutil.rmtree('_site') + + self.assertIn('Home', s1) + self.assertIn('Proin Quam - Lorem Ipsum', s1) + self.assertIn('Published on 2018-01-01 by Admin', s1) + + self.assertIn('http://localhost:8000/', s2) + self.assertIn('http://localhost:8000/blog/proin-quam/', s2) + + def test_json_params(self): + params = { + 'base_path': '/base', + 'subtitle': 'Foo', + 'author': 'Bar', + 'site_url': 'http://localhost/base' + } + with open('params.json', 'w') as f: + json.dump(params, f) + makesite.main() + + with open('_site/blog/proin-quam/index.html') as f: + s1 = f.read() + + with open('_site/blog/rss.xml') as f: + s2 = f.read() + + shutil.rmtree('_site') + + self.assertIn('Home', s1) + self.assertIn('Proin Quam - Foo', s1) + self.assertIn('Published on 2018-01-01 by Bar', s1) + + self.assertIn('http://localhost/base/', s2) + self.assertIn('http://localhost/base/blog/proin-quam/', s2) diff --git a/test/test_pages.py b/test/test_pages.py new file mode 100644 index 0000000..5fe7a4d --- /dev/null +++ b/test/test_pages.py @@ -0,0 +1,63 @@ +import unittest +import os +import shutil +import makesite +from test import path + +class PagesTest(unittest.TestCase): + def setUp(self): + self.blog_path = path.temppath('blog') + self.site_path = path.temppath('site') + os.makedirs(self.blog_path) + + with open(os.path.join(self.blog_path, 'foo.txt'), 'w') as f: + f.write('Foo') + with open(os.path.join(self.blog_path, 'bar.txt'), 'w') as f: + f.write('Bar') + with open(os.path.join(self.blog_path, '2018-01-01-foo.txt'), 'w') as f: + f.write('Foo') + with open(os.path.join(self.blog_path, '2018-01-02-bar.txt'), 'w') as f: + f.write('Bar') + + def tearDown(self): + shutil.rmtree(self.blog_path) + shutil.rmtree(self.site_path) + + def test_pages_undated(self): + src = os.path.join(self.blog_path, '[fb]*.txt') + dst = os.path.join(self.site_path, '{{ slug }}.txt') + tpl = '
{{ content }}
' + makesite.make_pages(src, dst, tpl) + with open(os.path.join(self.site_path, 'foo.txt')) as f: + self.assertEqual(f.read(), '
Foo
') + with open(os.path.join(self.site_path, 'bar.txt')) as f: + self.assertEqual(f.read(), '
Bar
') + + def test_pages_dated(self): + src = os.path.join(self.blog_path, '2*.txt') + dst = os.path.join(self.site_path, '{{ slug }}.txt') + tpl = '
{{ content }}
' + makesite.make_pages(src, dst, tpl) + with open(os.path.join(self.site_path, 'foo.txt')) as f: + self.assertEqual(f.read(), '
Foo
') + with open(os.path.join(self.site_path, 'bar.txt')) as f: + self.assertEqual(f.read(), '
Bar
') + + def test_pages_layout_params(self): + src = os.path.join(self.blog_path, '2*.txt') + dst = os.path.join(self.site_path, '{{ slug }}.txt') + tpl = '
{{ slug }}:{{ title }}:{{ date }}:{{ content }}
' + makesite.make_pages(src, dst, tpl, title='Lorem') + with open(os.path.join(self.site_path, 'foo.txt')) as f: + self.assertEqual(f.read(), '
foo:Lorem:2018-01-01:Foo
') + with open(os.path.join(self.site_path, 'bar.txt')) as f: + self.assertEqual(f.read(), '
bar:Lorem:2018-01-02:Bar
') + + def test_pages_return_value(self): + src = os.path.join(self.blog_path, '2*.txt') + dst = os.path.join(self.site_path, '{{ slug }}.txt') + tpl = '
{{ content }}
' + posts = makesite.make_pages(src, dst, tpl) + self.assertEqual(len(posts), 2) + self.assertEqual(posts[0]['date'], '2018-01-02') + self.assertEqual(posts[1]['date'], '2018-01-01') diff --git a/test/test_path.py b/test/test_path.py new file mode 100644 index 0000000..91e76e8 --- /dev/null +++ b/test/test_path.py @@ -0,0 +1,78 @@ +import unittest +import os +import shutil + +from test import path + +class PathTest(unittest.TestCase): + def test_temppath(self): + self.assertTrue(path.temppath()) + + def test_move_existing_file(self): + src = os.path.join(path.temppath(), 'foo.txt') + dst = os.path.join(path.temppath(), 'bar.txt') + with open(src, 'w') as f: + f.write('foo') + + path.move(src, dst) + self.assertFalse(os.path.isfile(src)) + self.assertTrue(os.path.isfile(dst)) + + with open(dst) as f: + text = f.read() + + os.remove(dst) + + self.assertEqual(text, 'foo') + + def test_move_missing_file(self): + src = os.path.join(path.temppath(), 'foo.txt') + dst = os.path.join(path.temppath(), 'bar.txt') + path.move(src, dst) + self.assertFalse(os.path.isfile(src)) + self.assertFalse(os.path.isfile(dst)) + + def test_move_file_cleanup(self): + src = os.path.join(path.temppath(), 'foo.txt') + dst = os.path.join(path.temppath(), 'bar.txt') + with open(dst, 'w') as f: + f.write('foo') + path.move(src, dst) + self.assertFalse(os.path.isfile(src)) + self.assertFalse(os.path.isfile(dst)) + + def test_move_existing_dir(self): + src = os.path.join(path.temppath(), 'foo') + srcf = os.path.join(src, 'foo.txt') + dst = os.path.join(path.temppath(), 'bar') + dstf = os.path.join(dst, 'foo.txt') + + os.makedirs(src) + with open(srcf, 'w') as f: + f.write('foo') + + path.move(src, dst) + self.assertFalse(os.path.isdir(src)) + self.assertTrue(os.path.isdir(dst)) + + with open(dstf) as f: + text = f.read() + + shutil.rmtree(dst) + + self.assertEqual(text, 'foo') + + def test_move_missing_dir(self): + src = os.path.join(path.temppath(), 'foo') + dst = os.path.join(path.temppath(), 'bar') + path.move(src, dst) + self.assertFalse(os.path.isdir(src)) + self.assertFalse(os.path.isdir(dst)) + + def test_move_dir_cleanup(self): + src = os.path.join(path.temppath(), 'foo') + dst = os.path.join(path.temppath(), 'bar') + os.makedirs(dst) + path.move(src, dst) + self.assertFalse(os.path.isdir(src)) + self.assertFalse(os.path.isdir(dst)) diff --git a/test/test_render.py b/test/test_render.py new file mode 100644 index 0000000..e036b70 --- /dev/null +++ b/test/test_render.py @@ -0,0 +1,25 @@ +import unittest +import makesite + +class RenderTest(unittest.TestCase): + """Tests for render() function.""" + + def test_oneline_template(self): + tpl = 'foo {{ key1 }} baz {{ key2 }}' + out = makesite.render(tpl, key1='bar', key2='qux') + self.assertEqual(out, 'foo bar baz qux') + + def test_multiline_template(self): + tpl = 'foo {{ key1 }}\nbaz {{ key1 }}' + out = makesite.render(tpl, key1='bar') + self.assertEqual(out, 'foo bar\nbaz bar') + + def test_repeated_key(self): + tpl = 'foo {{ key1 }} baz {{ key1 }}' + out = makesite.render(tpl, key1='bar') + self.assertEqual(out, 'foo bar baz bar') + + def test_multiline_placeholder(self): + tpl = 'foo {{\nkey1\n}} baz {{\nkey2\n}}' + out = makesite.render(tpl, key1='bar', key2='qux') + self.assertEqual(out, 'foo bar baz qux') -- cgit v1.2.3