├── pages ├── 404.text └── 50x.text ├── templates ├── tweet.template ├── basic.html ├── index.html ├── article.html ├── text.html ├── atom_entry.xml ├── atom.xml ├── base.html └── article_standalone.html ├── www └── example.html ├── post-receive ├── requirements.txt ├── config ├── LICENSE.txt ├── README.md ├── articles └── 2017 │ └── 10 │ └── static_fire_readme.text └── static_fire.py /pages/404.text: -------------------------------------------------------------------------------- 1 | 404 2 | === 3 | -------------------------------------------------------------------------------- /pages/50x.text: -------------------------------------------------------------------------------- 1 | Error 2 | ===== 3 | -------------------------------------------------------------------------------- /templates/tweet.template: -------------------------------------------------------------------------------- 1 | {{ title }} 2 | {{ permalink }} 3 | -------------------------------------------------------------------------------- /templates/basic.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | {{ content }} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /www/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Static Fire 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 | {% for article in articles %} 4 | {% include "article.html" %} 5 | {% endfor %} 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /post-receive: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | WORKING_DIR=$(pwd) 4 | if [ "$(basename "$WORKING_DIR")" == ".git" ] 5 | then 6 | WORKING_DIR=$(dirname "$WORKING_DIR") 7 | fi 8 | 9 | pushd "$WORKING_DIR" 10 | ./static_fire.py 11 | popd 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi 2 | chardet 3 | gitdb2 4 | GitPython 5 | idna 6 | Jinja2 7 | Markdown 8 | MarkupSafe 9 | oauthlib 10 | python-dateutil 11 | pytz 12 | requests 13 | requests-oauthlib 14 | six 15 | smmap2 16 | tweepy 17 | urllib3 18 | -------------------------------------------------------------------------------- /config: -------------------------------------------------------------------------------- 1 | # Example Config 2 | # Don't commit this file. It will contain OAuth secrets 3 | www /usr/local/var/www 4 | domain http://localhost:8080 5 | title Static Fire 6 | author Rob Cusimano 7 | date_format %B %d, %Y # strftime behavior 8 | homepage_count 50 9 | -------------------------------------------------------------------------------- /templates/article.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | {{ article.title }} 4 |

5 |
{{ article.create_date }}
6 | {{ article.content }} 7 |
8 |
9 | -------------------------------------------------------------------------------- /templates/text.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
 8 | {{ article.title }}
 9 | {{ '=' * article.title|length() }}
10 | 
11 |     By {{ author }}
12 |     {{ article.date }}
13 |     {{ 'Link: %s' % article.link if article.link }}
14 |     {{ article.permalink }}
15 | 
16 | {{ article.plain_text }}
17 | 
18 | 19 | 20 | -------------------------------------------------------------------------------- /templates/atom_entry.xml: -------------------------------------------------------------------------------- 1 | 2 | {{ article.permalink }} 3 | {{ article.title }} 4 | {{ article.updated }} 5 | {{ article.published }} 6 | 7 | 8 | 9 | {{ author }} 10 | {{ domain }} 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/atom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ domain }}/feeds/atom.xml 4 | {{ title }} 5 | {{ updated }} 6 | 7 | 8 | Copyright © {{ author }} 9 | By {{ author }} 10 | {% for article in articles %} 11 | {% include "atom_entry.xml" %} 12 | {% endfor %} 13 | 14 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ page_title }} 7 | 8 | {% block social %}{% endblock %} 9 | 10 | 11 |
12 | {% block content %}{% endblock %} 13 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/article_standalone.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block social %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% endblock %} 16 | {% block content %} 17 | {% include "article.html" %} 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Robert Cusimano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Static Fire 2 | =========== 3 | 4 | Static Fire is an engine for creating static websites, primarily blogs. 5 | Instead of a standard database Static Fire uses Git as its primary data store. 6 | Static Fire was built by [@RobertCusimano](https://twitter.com/RobertCusimano) and powers [ShadedTriangle](https://ShadedTriangle.com). 7 | 8 | ## Features 9 | + Markdown articles 10 | + [Jinja2](http://jinja.pocoo.org/docs/2.9/) templating 11 | + Linked list style articles 12 | + Git based datastore 13 | + No database management 14 | + Publish from your phone with any old Git client (I like [Working Copy](https://workingcopyapp.com/)) 15 | + Atom feed generation 16 | + Tweet new articles 17 | + Twitter Cards and Facebook Open Graph meta tags built in 18 | + Plain text articles 19 | + Cloudflare API integration to purge static files from Cloudflare's cache 20 | 21 | ## Installation (Local) 22 | # Git clone then... 23 | pip install -r requirements.txt 24 | 25 | ## Installation (Webserver) 26 | # Git clone or git push to webserver. You want a normal repository not a bare repository. Then... 27 | pip install -r requirements.txt 28 | cp post-receive .git/hooks/ # Will run static_fire.py after a git push is received 29 | git config --local receive.denyCurrentBranch updateInstead # Allows a git push to update the local working copy 30 | 31 | ## Usage 32 | ./static_fire.py 33 | 34 | All the action takes place in static\_fire.py. 35 | You can run it right now and it should generate an example website for you in "/usr/local/var/www". 36 | Place new plain text Markdown files ending with ".text" in the articles folder, commit them to git, and the next time Static Fire runs it will generate html pages for them. 37 | 38 | ### Article Header 39 | Articles are standard Markdown files except for a small header for metadata. Metadata lines are a simple key and value that splits on the first space. A sinlge line of "end_header" marks the end of the header. An example header might look like: 40 | 41 | title Markdown Syntax 42 | summary A link to DaringFireball's Markdown Syntax page. 43 | link https://daringfireball.net/projects/markdown/syntax 44 | end_header 45 | 46 | + **title** 47 | 48 | *Required*. The title of the article used for page titles, headers, tweets, feeds, and the archive. 49 | 50 | + **summary** 51 | 52 | *Optional*. The summary of the article is used for tweets and feeds and is completely optional. If missing summaries will be populated from the title. 53 | 54 | + **link** 55 | 56 | *Optional*. If your article is primarily a link to some other content this will make the header of the article be a link. This will also populate the feed entry with an "alternate" of the link and a "related" of the url of the article instead of the usual which is an "alternate" to the article itself. 57 | 58 | ### Config 59 | Static Fire looks for a file called "config" next to it. Config lines are simple key values that split on the first space. 60 | An example config looks like: 61 | 62 | # Example Config 63 | # Don't commit this file. It will contain OAuth secrets 64 | www /usr/local/var/www # Website dir 65 | domain http://localhost:8080 66 | title Static Fire 67 | author Rob Cusimano 68 | date_format %B %d, %Y # strftime behavior 69 | homepage_count 50 # Count of articles on homepage and in Atom feed 70 | 71 | #### Required Config Keys 72 | + **www** 73 | 74 | *String*. The location your website is served from. 75 | 76 | + **domain** 77 | 78 | *String*. Your website's domain. For example: https://shadedtriangle.com 79 | 80 | + **title** 81 | 82 | *String*. Your website's title for its homepage and feeds. 83 | 84 | + **author** 85 | 86 | *String*. Your name here. 87 | 88 | + **date\_format** 89 | 90 | *String*. A nice format to print dates for your articles in Python's strftime format. 91 | 92 | + **homepage\_count** 93 | 94 | *Int*. The number of most-recent articles to appear on the homepage and feeds. 95 | 96 | #### Optional Config Keys 97 | "twtr\*" keys are used collectively for auto tweeting new articles. No keys, no tweets. 98 | 99 | + **twtr\_access\_token** 100 | 101 | *String* 102 | 103 | + **twtr\_access\_token\_secret** 104 | 105 | *String* 106 | 107 | + **twtr\_consumer\_key** 108 | 109 | *String* 110 | 111 | + **twtr\_consumer\_secret** 112 | 113 | *String* 114 | 115 | "cloudflare\*" keys are used to purge static content from Cloudflare's cache when that content changes. 116 | 117 | + **cloudflare\_email** 118 | 119 | *String* 120 | 121 | + **cloudflare\_zone\_id** 122 | 123 | *String* 124 | 125 | + **cloudflare\_secret\_api\_key** 126 | 127 | *String* 128 | 129 | ### Static Fire's Actions 130 | static\_fire.py performs the following actions: 131 | 132 | 1. Load config and templates 133 | 2. Gather articles from Git 134 | 3. Copy the contents of www/ to config's "www" 135 | + Useful for css, images, etc. 136 | 4. Build each article page 137 | + If it's a new article, tweet it 138 | 5. Build homepage 139 | 6. Build feeds 140 | 7. Build archive page 141 | 8. Build additonal pages 142 | 9. Purge files from Cloudflare's cache 143 | 144 | ### Additional Pages 145 | Markdown files ending with ".text" in the "pages/" directory will be compiled into .html files and placed at the root of your website. This is useful for about pages or error pages like 404, 503 etc. The first line of the file should be the title of the page followed by the second line of "==" for Markdown. 146 | 147 | ### Post Receive Git Hook 148 | post-receive is a simple git hook to execute static\_fire.py after a git push is received. 149 | post-receive should be installed in ".git/hooks/" on your webserver to enable site re-building when you push new articles. 150 | 151 | ### Auto Tweet 152 | Static Fire will tweet articles if the commit they were authored in is HEAD when static\_fire.py runs. 153 | You need to provide the four "twtr" keys in your config for auto tweet to work. 154 | You can get these keys by creating a [Twitter app for your account](https://apps.twitter.com/). 155 | Make sure your app was write and read access. 156 | 157 | *Don't commit or share your config file! Especially if you store your Twitter app's OAuth keys in it.* 158 | 159 | ## Extra Bits 160 | 161 | ### Lying About Dates 162 | Static Fire uses "author date" as the date an article was written. 163 | You can pick an author date at commit time with the "git commit --date" option. 164 | This won't change when an article appears on the your site. Only the date value under headers and in feeds. 165 | 166 | ### Text articles 167 | Static Fire generates .text files next to your .html files for your website. This lets you see the Markdown of your article. For example: 168 | 169 | https://shadedtriangle.com/articles/2017/10/review_golf_story.text 170 | 171 | ### Markdown Configuration 172 | Markdown is configured by default to support footnotes[^1] and SmartPants style formatting. 173 | 174 | ### I Can't Commit my "config" File 175 | You really shouldn't because it can have sensitive information. 176 | To discourage you I hid it from git. 177 | You can have it reappear in your working tree with. 178 | 179 | git upate-index --no-skip-worktree config 180 | 181 | You can hide "config" again with: 182 | 183 | git update-index --skip-worktree config 184 | 185 | [^1]: Like this. 186 | -------------------------------------------------------------------------------- /articles/2017/10/static_fire_readme.text: -------------------------------------------------------------------------------- 1 | title Static Fire 2 | end_header 3 | 4 | Static Fire is an engine for creating static websites, primarily blogs. 5 | Instead of a standard database Static Fire uses Git as its primary data store. 6 | Static Fire was built by [@RobertCusimano](https://twitter.com/RobertCusimano) and powers [ShadedTriangle](https://ShadedTriangle.com). 7 | 8 | ## Features 9 | + Markdown articles 10 | + [Jinja2](http://jinja.pocoo.org/docs/2.9/) templating 11 | + Linked list style articles 12 | + Git based datastore 13 | + No database management 14 | + Publish from your phone with any old Git client (I like [Working Copy](https://workingcopyapp.com/)) 15 | + Atom feed generation 16 | + Tweet new articles 17 | + Twitter Cards and Facebook Open Graph meta tags built in 18 | + Plain text articles 19 | + Cloudflare API integration to purge static files from Cloudflare's cache 20 | 21 | ## Installation (Local) 22 | # Git clone then... 23 | pip install -r requirements.txt 24 | 25 | ## Installation (Webserver) 26 | # Git clone or git push to webserver. You want a normal repository not a bare repository. Then... 27 | pip install -r requirements.txt 28 | cp post-receive .git/hooks/ # Will run static_fire.py after a git push is received 29 | git config --local receive.denyCurrentBranch updateInstead # Allows a git push to update the local working copy 30 | 31 | ## Usage 32 | ./static_fire.py 33 | 34 | All the action takes place in static\_fire.py. 35 | You can run it right now and it should generate an example website for you in "/usr/local/var/www". 36 | Place new plain text Markdown files ending with ".text" in the articles folder, commit them to git, and the next time Static Fire runs it will generate html pages for them. 37 | 38 | ### Article Header 39 | Articles are standard Markdown files except for a small header for metadata. Metadata lines are a simple key and value that splits on the first space. A sinlge line of "end_header" marks the end of the header. An example header might look like: 40 | 41 | title Markdown Syntax 42 | summary A link to DaringFireball's Markdown Syntax page. 43 | link https://daringfireball.net/projects/markdown/syntax 44 | end_header 45 | 46 | + **title** 47 | 48 | *Required*. The title of the article used for page titles, headers, tweets, feeds, and the archive. 49 | 50 | + **summary** 51 | 52 | *Optional*. The summary of the article is used for tweets and feeds and is completely optional. If missing summaries will be populated from the title. 53 | 54 | + **link** 55 | 56 | *Optional*. If your article is primarily a link to some other content this will make the header of the article be a link. This will also populate the feed entry with an "alternate" of the link and a "related" of the url of the article instead of the usual which is an "alternate" to the article itself. 57 | 58 | ### Config 59 | Static Fire looks for a file called "config" next to it. Config lines are simple key values that split on the first space. 60 | An example config looks like: 61 | 62 | # Example Config 63 | # Don't commit this file. It will contain OAuth secrets 64 | www /usr/local/var/www # Website dir 65 | domain http://localhost:8080 66 | title Static Fire 67 | author Rob Cusimano 68 | date_format %B %d, %Y # strftime behavior 69 | homepage_count 50 # Count of articles on homepage and in Atom feed 70 | 71 | #### Required Config Keys 72 | + **www** 73 | 74 | *String*. The location your website is served from. 75 | 76 | + **domain** 77 | 78 | *String*. Your website's domain. For example: https://shadedtriangle.com 79 | 80 | + **title** 81 | 82 | *String*. Your website's title for its homepage and feeds. 83 | 84 | + **author** 85 | 86 | *String*. Your name here. 87 | 88 | + **date\_format** 89 | 90 | *String*. A nice format to print dates for your articles in Python's strftime format. 91 | 92 | + **homepage\_count** 93 | 94 | *Int*. The number of most-recent articles to appear on the homepage and feeds. 95 | 96 | #### Optional Config Keys 97 | "twtr\*" keys are used collectively for auto tweeting new articles. No keys, no tweets. 98 | 99 | + **twtr\_access\_token** 100 | 101 | *String* 102 | 103 | + **twtr\_access\_token\_secret** 104 | 105 | *String* 106 | 107 | + **twtr\_consumer\_key** 108 | 109 | *String* 110 | 111 | + **twtr\_consumer\_secret** 112 | 113 | *String* 114 | 115 | "cloudflare\*" keys are used to purge static content from Cloudflare's cache when that content changes. 116 | 117 | + **cloudflare\_email** 118 | 119 | *String* 120 | 121 | + **cloudflare\_zone\_id** 122 | 123 | *String* 124 | 125 | + **cloudflare\_secret\_api\_key** 126 | 127 | *String* 128 | 129 | 130 | ### Static Fire's Actions 131 | static\_fire.py performs the following actions: 132 | 133 | 1. Load config and templates 134 | 2. Gather articles from Git 135 | 3. Copy the contents of www/ to config's "www" 136 | + Useful for css, images, etc. 137 | 4. Build each article page 138 | + If it's a new article, tweet it 139 | 5. Build homepage 140 | 6. Build feeds 141 | 7. Build archive page 142 | 8. Build additonal pages 143 | 9. Purge files from Cloudflare's cache 144 | 145 | ### Additional Pages 146 | Markdown files ending with ".text" in the "pages/" directory will be compiled into .html files and placed at the root of your website. This is useful for about pages or error pages like 404, 503 etc. The first line of the file should be the title of the page followed by the second line of "==" for Markdown. 147 | 148 | ### Post Receive Git Hook 149 | post-receive is a simple git hook to execute static\_fire.py after a git push is received. 150 | post-receive should be installed in ".git/hooks/" on your webserver to enable site re-building when you push new articles. 151 | 152 | ### Auto Tweet 153 | Static Fire will tweet articles if the commit they were authored in is HEAD when static\_fire.py runs. 154 | You need to provide the four "twtr" keys in your config for auto tweet to work. 155 | You can get these keys by creating a [Twitter app for your account](https://apps.twitter.com/). 156 | Make sure your app was write and read access. 157 | 158 | *Don't commit or share your config file! Especially if you store your Twitter app's OAuth keys in it.* 159 | 160 | ## Extra Bits 161 | 162 | ### Lying About Dates 163 | Static Fire uses "author date" as the date an article was written. 164 | You can pick an author date at commit time with the "git commit --date" option. 165 | This won't change when an article appears on the your site. Only the date value under headers and in feeds. 166 | 167 | ### Text articles 168 | Static Fire generates .text files next to your .html files for your website. This lets you see the Markdown of your article. For example: 169 | 170 | https://shadedtriangle.com/articles/2017/10/review_golf_story.text 171 | 172 | ### Markdown Configuration 173 | Markdown is configured by default to support footnotes[^1] and SmartPants style formatting. 174 | 175 | ### I Can't Commit my "config" File 176 | You really shouldn't because it can have sensitive information. 177 | To discourage you I hid it from git. 178 | You can have it reappear in your working tree with. 179 | 180 | git upate-index --no-skip-worktree config 181 | 182 | You can hide "config" again with: 183 | 184 | git update-index --skip-worktree config 185 | 186 | [^1]: Like this. 187 | -------------------------------------------------------------------------------- /static_fire.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import codecs 4 | import datetime 5 | import errno 6 | import os 7 | import pytz 8 | import shutil 9 | import sys 10 | import time 11 | import requests 12 | 13 | import dateutil.parser 14 | from distutils.dir_util import copy_tree 15 | from operator import itemgetter 16 | from markupsafe import Markup 17 | from git import Repo 18 | 19 | class Article: 20 | def __init__(self, path, created, updated, is_new): 21 | self.path = path 22 | self.created = created 23 | self.updated = updated 24 | self.is_new = is_new 25 | 26 | # Filled in when read() is called 27 | self.template_vars = dict() 28 | self.full_text = None 29 | self.title = None 30 | self.summary = None 31 | self.link = None 32 | self.full_url = None 33 | 34 | def read(self, config, md): 35 | input_file = codecs.open(os.path.join(config["blog"], self.path), mode="r", encoding="utf-8") 36 | 37 | line = input_file.readline().rstrip() 38 | while(line != "end_header"): 39 | key, remainder = line.split(" ", 1) 40 | if (key == "title"): 41 | self.title = remainder 42 | elif (key == "summary"): 43 | self.summary = remainder 44 | elif (key == "link"): 45 | self.link = remainder 46 | else: 47 | print("Unrecognized metadata: " + line) 48 | line = input_file.readline().rstrip() 49 | 50 | self.full_text = input_file.read() 51 | input_file.close() 52 | root, _ = os.path.splitext(self.path) 53 | self.full_url = os.path.join(config["domain"], root) + ".html" 54 | 55 | self.template_vars["content"] = Markup(md.reset().convert(self.full_text)) 56 | self.template_vars["published"] = self.created.isoformat() 57 | self.template_vars["updated"] = self.updated.isoformat() 58 | self.template_vars["plain_text"] = self.full_text 59 | self.template_vars["create_date"] = self.created.strftime(config["date_format"]) 60 | self.template_vars["link"] = self.link 61 | self.template_vars["permalink"] = self.full_url 62 | self.template_vars["href"] = self.link if self.link else self.full_url 63 | self.template_vars["title"] = self.title 64 | self.template_vars["title_class"] = "link" if self.link else "" 65 | if (self.link is not None): 66 | self.template_vars["summary"] = "Link To: " + self.link 67 | else: 68 | self.template_vars["summary"] = self.summary if self.summary is not None else self.title 69 | 70 | def __repr__(self): 71 | return self.path + " created: " + unicode(self.created) + " updated: " + unicode(self.updated) 72 | 73 | def load_config(): 74 | print("Config") 75 | config = {} 76 | config["blog"] = os.getcwd() 77 | print("Assinging 'blog' as '" + config["blog"] + "'") 78 | with open(os.path.join(config["blog"], "config"), "r") as config_f: 79 | for line in config_f: 80 | line = line.split("#")[0] 81 | if not line or not line.rstrip(): 82 | continue 83 | key, value = line.split(" ", 1) 84 | value = value.rstrip() 85 | if "secret" not in key: 86 | print("Assinging '" + key + "' as '" + value + "'") 87 | else: 88 | print("Assinging '" + key + "' as '" + len(value)*"*" + "'") 89 | config[key] = value 90 | return config 91 | 92 | def load_templates(config): 93 | print("Templates") 94 | from jinja2 import Environment, FileSystemLoader, select_autoescape 95 | env = Environment( 96 | loader=FileSystemLoader(os.path.join(config["blog"], "templates")), 97 | autoescape=select_autoescape(['html', 'xml']), 98 | trim_blocks=True, 99 | ) 100 | env.globals["title"] = config["title"] 101 | env.globals["author"] = config["author"] 102 | env.globals["domain"] = config["domain"] 103 | env.globals["year"] = str(datetime.datetime.now().year) 104 | return env 105 | 106 | def load_markdown(): 107 | import markdown 108 | extensions = ["markdown.extensions.footnotes", "markdown.extensions.smarty"] 109 | extension_configs = { 110 | "markdown.extensions.footnotes": { "UNIQUE_IDS": True } 111 | } 112 | md = markdown.Markdown(extensions=extensions, extension_configs=extension_configs) 113 | return md 114 | 115 | def get_twitter_api(config): 116 | keys = ("twtr_consumer_key", "twtr_consumer_secret", "twtr_access_token", "twtr_access_token_secret") 117 | if not all (k in config for k in keys): 118 | return None 119 | import tweepy 120 | auth = tweepy.OAuthHandler(config["twtr_consumer_key"], config["twtr_consumer_secret"]) 121 | auth.set_access_token(config["twtr_access_token"], config["twtr_access_token_secret"]) 122 | return tweepy.API(auth) 123 | 124 | def makedirs(path): 125 | try: 126 | os.makedirs(path) 127 | except OSError as e: 128 | if e.errno != errno.EEXIST: 129 | raise 130 | 131 | def build_page(config, md, templates, page_file_name): 132 | engine_path = config["blog"] 133 | input_file = codecs.open(os.path.join(engine_path, "pages", page_file_name), mode="r", encoding="utf-8") 134 | root, ext = os.path.splitext(page_file_name) 135 | output_path = os.path.join(config["www"], root + ".html") 136 | 137 | text = input_file.read() 138 | input_file.close() 139 | 140 | template_vars = dict() 141 | template_vars["page_title"] = text.split("\n", 1)[0] 142 | template_vars["content"] = Markup(md.reset().convert(text)) 143 | 144 | template = templates.get_template("basic.html") 145 | html = template.render(template_vars) 146 | 147 | output_file = codecs.open(output_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 148 | output_file.write(html) 149 | output_file.close() 150 | 151 | def build_article(config, md, templates, article): 152 | engine_path = config["blog"] 153 | root, ext = os.path.splitext(article.path) 154 | 155 | output_path = os.path.join(config["www"], root + ".html") 156 | output_text_path = os.path.join(config["www"], root + ".text") 157 | 158 | makedirs(os.path.dirname(output_path)) 159 | 160 | template_vars = dict() 161 | template_vars["page_title"] = article.title 162 | template_vars["article"] = article.template_vars 163 | 164 | # Plain text article 165 | output_text_file = codecs.open(output_text_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 166 | plain_text = templates.get_template("text.html") 167 | text_html = plain_text.render(template_vars) 168 | output_text_file.write(text_html) 169 | output_text_file.close() 170 | 171 | # HTML article 172 | output_file = codecs.open(output_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 173 | html = templates.get_template("article_standalone.html") 174 | final_html = html.render(template_vars) 175 | output_file.write(final_html) 176 | output_file.close() 177 | 178 | def build_homepage(config, md, templates, homepage_articles): 179 | template_vars = dict() 180 | template_vars["page_title"] = config["title"] 181 | template_vars["articles"] = homepage_articles 182 | 183 | template = templates.get_template("index.html") 184 | html = template.render(template_vars) 185 | 186 | output_path = os.path.join(config["www"], "index.html") 187 | makedirs(os.path.dirname(output_path)) 188 | output_file = codecs.open(output_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 189 | output_file.write(html) 190 | output_file.close() 191 | 192 | def build_archive(config, md, templates, articles): 193 | import calendar 194 | 195 | text = "" 196 | year = None 197 | month = None 198 | for article in articles: 199 | if (year != article.created.year): 200 | year = article.created.year 201 | text += '

' + str(year) + '

\n' 202 | if (month != article.created.month): 203 | month = article.created.month 204 | text += '

' + calendar.month_name[month] + '

\n' 205 | 206 | text += '' + article.title + '
' 207 | 208 | template_vars = dict() 209 | template_vars["page_title"] = "Archive" 210 | template_vars["content"] = Markup(text) 211 | 212 | template = templates.get_template("basic.html") 213 | html = template.render(template_vars) 214 | 215 | output_path = os.path.join(config["www"], "archive.html") 216 | makedirs(os.path.dirname(output_path)) 217 | output_file = codecs.open(output_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 218 | 219 | output_file.write(html) 220 | output_file.close() 221 | 222 | def build_feeds(config, md, templates, articles): 223 | atom_template = templates.get_template("atom.xml") 224 | 225 | template_vars = dict() 226 | template_vars["articles"] = articles 227 | template_vars["updated"] = datetime.datetime.now(pytz.utc).isoformat() 228 | atom_feed = atom_template.render(template_vars) 229 | 230 | atom_path = os.path.join(config["www"], "feeds", "atom.xml") 231 | makedirs(os.path.dirname(atom_path)) 232 | 233 | output_file = codecs.open(atom_path, mode="w", encoding="utf-8", errors="xmlcarrefreplace") 234 | output_file.write(atom_feed) 235 | output_file.close() 236 | 237 | def build_tweet(config, templates, article): 238 | tweet = templates.get_template("tweet.template").render(article.template_vars) 239 | print("Tweet: " + tweet) 240 | return tweet 241 | 242 | def query_git_articles(config): 243 | print("Git") 244 | repo = Repo(config["blog"]) 245 | git = repo.git 246 | 247 | articles = list() 248 | article_files = git.ls_files("articles/*.text").split("\n") 249 | head_rev = git.show("-s", "HEAD", "--format=\"%H\"").strip("\"") 250 | for article_file in article_files: 251 | if not os.path.isfile(article_file): 252 | continue 253 | article_revs = git.rev_list("HEAD", article_file).split("\n") 254 | first_rev = article_revs[-1] 255 | last_rev = article_revs[0] 256 | timestamps = git.show("-s", "--format=\"%aI\"", first_rev, last_rev).split("\n") 257 | first_timestamp = timestamps[0].strip("\"") 258 | last_timestamp = first_timestamp 259 | if len(timestamps) > 1: 260 | last_timestamp = timestamps[1].strip("\"") 261 | 262 | created = dateutil.parser.parse(first_timestamp) 263 | updated = dateutil.parser.parse(last_timestamp) 264 | 265 | is_new = (head_rev == first_rev) 266 | 267 | a = Article(article_file, created, updated, is_new) 268 | articles.append(a) 269 | 270 | articles.sort(key=lambda a: a.created, reverse=True) 271 | return articles 272 | 273 | def build_purge_list(config): 274 | print("Build Purge List") 275 | repo = Repo(config["blog"]) 276 | git = repo.git 277 | 278 | domain = config['domain'] 279 | changed = [] 280 | changed.append(domain) # Always purge homepage 281 | changed.append(os.path.join(domain, "feeds", "atom.xml")) # Purge atom feed 282 | changed.append(os.path.join(domain, "archive")) # Purge archive 283 | 284 | changed_articles = git.diff_tree("--no-commit-id", "--name-only", "-r", "HEAD", "articles").split("\n") 285 | for article in changed_articles: 286 | if not article: 287 | continue 288 | changed.append(os.path.join(domain, os.path.splitext(article)[0])) 289 | changed.append(os.path.join(domain, os.path.splitext(article)[0] + ".text")) 290 | 291 | changed_pages = git.diff_tree("--no-commit-id", "--name-only", "-r", "HEAD", "pages").split("\n") 292 | for page in changed_pages: 293 | if not page: 294 | continue 295 | changed.append(os.path.join(domain, os.path.splitext(page)[0][6:])) 296 | 297 | changed_www = git.diff_tree("--no-commit-id", "--name-only", "-r", "HEAD", "www").split("\n") 298 | for f in changed_www: 299 | if not f: 300 | continue 301 | changed.append(os.path.join(domain, f[4:])) 302 | 303 | return changed 304 | 305 | def cloudflare_purge_cache(config, files): 306 | keys = ("cloudflare_email", "cloudflare_zone_id", "cloudflare_secret_api_key") 307 | if not all (k in config for k in keys): 308 | return None 309 | print('Purging Cloudflare Cache') 310 | print(files) 311 | 312 | purge_paths = dict() 313 | purge_paths['files'] = files 314 | 315 | headers = {'X-Auth-Email': config['cloudflare_email'], 316 | 'X-Auth-Key': config['cloudflare_secret_api_key'], 317 | 'Content-Type': 'application/json' 318 | } 319 | r = requests.post('https://api.cloudflare.com/client/v4/zones/{}/purge_cache'.format(config['cloudflare_zone_id']), 320 | json=purge_paths, 321 | headers=headers) 322 | 323 | if r.status_code != 200: 324 | print(r.json()) 325 | 326 | def main(argv): 327 | config = load_config() 328 | os.environ["GIT_DIR"] = os.path.join(config["blog"], ".git") 329 | templates = load_templates(config) 330 | md = load_markdown() 331 | 332 | website_dir = config["www"] 333 | 334 | articles = query_git_articles(config) 335 | 336 | homepage_articles = list() 337 | 338 | copy_tree(os.path.join(config["blog"], "www"), website_dir) 339 | 340 | for article in articles: 341 | if article.path.endswith(".text"): 342 | article.read(config, md) 343 | build_article(config, md, templates, article) 344 | if len(homepage_articles) < int(config["homepage_count"]): 345 | homepage_articles.append(article.template_vars) 346 | if (article.is_new): 347 | twitter = get_twitter_api(config) 348 | if (twitter): 349 | tweet = build_tweet(config, templates, article) 350 | status = twitter.update_status(status=tweet) 351 | 352 | build_homepage(config, md, templates, homepage_articles) 353 | build_feeds(config, md, templates, homepage_articles) 354 | build_archive(config, md, templates, articles) 355 | 356 | # Extra pages 357 | for filename in os.listdir(os.path.join(config["blog"], "pages")): 358 | if filename.endswith(".text"): 359 | build_page(config, md, templates, filename) 360 | 361 | paths_to_purge = build_purge_list(config) 362 | cloudflare_purge_cache(config, paths_to_purge) 363 | 364 | if __name__ == "__main__": 365 | main(sys.argv) 366 | 367 | --------------------------------------------------------------------------------