pelican-subtle-giggi: update release v1
diff --git a/theme/README.md b/theme/README.md
new file mode 100644
index 0000000..7572040
--- /dev/null
+++ b/theme/README.md
@@ -0,0 +1,97 @@
+Subtle Pelican Theme
+====================
+
+[pelican-subtle](https://github.com/pR0Ps/pelican-subtle) is a responsive theme for [Pelican](http://getpelican.com), it is my take on Giulio Fidente's [pelican-svbhack](https://github.com/giulivo/pelican-svbhack) theme.
+
+## Demo
+
+You can see the theme in action on [my site](http://cmetcalfe.ca/) ([site source code](https://github.com/pR0Ps/website)).
+
+
+
+## Features
+
+- Clean, responsive design. Scales to fit any screen
+- Fully HTML5 compliant
+- Fully usable without Javascript (although enabling it adds more functionality)
+- Ability to hide email addresses from spambots
+- Syntax highlighting for code blocks
+- Integrated Google Analytics
+- Custom sidebar links and social icons
+- Fuzzy date display (ex: "Posted 2 days ago") using Javascript
+- Disqus comment integration
+- Automatically minifies and compresses Javascript and CSS
+
+## Dependencies
+
+For automatic minification and compression of assets, this theme uses the
+[webassets](https://webassets.readthedocs.org/) package, along with `cssmin` for
+minifying css, `slimit` for compressing Javascript, and `libsass` for
+compiling the [SCSS](http://www.sass-lang.com) styles.
+
+- All these can be installed with `pip` by running `pip install -r requirements.txt`
+
+Additionally, to integrate the `webassets` package into pelican, the [pelican-assets plugin](https://github.com/pR0Ps/pelican-assets)
+is required. It's included in this repo as a submodule so it just needs to be
+activated in `pelicanconf.py`.
+
+- Add `"<theme dir>/plugins"` to the `PLUGIN_PATHS` list (or `os.path.join("<theme dir>", "plugins")` if you're feeling Pythonic)
+- Add `"assets"` to the `PLUGINS` list
+
+## Installing
+
+1. Clone [this repository](https://github.com/pR0Ps/pelican-subtle) making sure to get the submodules (ex: `git clone --recursive <repo>`)
+2. Install and configure the required dependencies (see above)
+3. Modify the `THEME` variable in `pelicanconf.py` to point to the cloned theme location
+4. Customize the theme using the options below
+
+## pelicanconf.py
+
+When developing locally, you may want to set `SITEURL` to something like `http://localhost:8000`
+
+This theme supports a number of custom variables:
+
+- `GOOGLE_ANALYTICS_ID`: Your Google Analytics UA-XXXXXXXX-X code (`None` to disable analytics).
+- `GOOGLE_ANALYTICS_PROP`: Your Google Analytics property name (`None` to disable analytics).
+- `DISPLAY_META_ABOVE_ARTICLE`: Set to `True` to display article information above the article (default is below).
+- `DISQUS_SITENAME`: Set this to enable Disqus comments on articles.
+- `DISQUS_COLLAPSED`: Set to `True` to have article comments hidden by default. Clicking a link on the page will load them.
+- `USER_LOGO_URL`: The image to display as the profile image. Can be local or a remote URL.
+- `TAGLINE`: The site's tagline. Rendered right below the user image.
+- `MANGLE_EMAILS`: If enabled, any emails in the `SOCIAL` or `LINKS` lists will be obfuscated in HTML and have their links generated by Javascript when the page loads.
+- `GLOBAL_KEYWORDS`: A list of strings that will be set as meta keywords for each page.
+- `FUZZY_DATES`: If `True`, uses [lw-timeago](https://github.com/pR0Ps/lw-timeago) to display fuzzy dates on articles (eg. 'Posted 3 months ago').
+
+## Authors
+
+pelican-subtle is authored by [pR0Ps](https://github.com/pR0Ps).
+
+pelican-svbhack is authored by [Giulio Fidente](https://github.com/giulivo).
+
+Pull requests welcome!
+
+## Licence
+```
+The MIT License (MIT)
+Copyright (c) 2012 William Ting
+Copyright (c) 2013 Giulio Fidente
+Copyright (c) 2015 Carey Metcalfe
+
+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/theme/plugins/assets/__init__.py b/theme/plugins/assets/__init__.py
new file mode 100644
index 0000000..67b75dd
--- /dev/null
+++ b/theme/plugins/assets/__init__.py
@@ -0,0 +1 @@
+from .assets import *
diff --git a/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc b/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc
new file mode 100644
index 0000000..581a1c8
--- /dev/null
+++ b/theme/plugins/assets/__pycache__/__init__.cpython-35.pyc
Binary files differ
diff --git a/theme/plugins/assets/__pycache__/assets.cpython-35.pyc b/theme/plugins/assets/__pycache__/assets.cpython-35.pyc
new file mode 100644
index 0000000..fde1266
--- /dev/null
+++ b/theme/plugins/assets/__pycache__/assets.cpython-35.pyc
Binary files differ
diff --git a/theme/plugins/assets/assets.py b/theme/plugins/assets/assets.py
new file mode 100644
index 0000000..e204dd6
--- /dev/null
+++ b/theme/plugins/assets/assets.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+"""
+Asset management plugin for Pelican
+===================================
+
+This plugin allows you to use the `webassets`_ module to manage assets such as
+CSS and JS files.
+
+The ASSET_URL is set to a relative url to honor Pelican's RELATIVE_URLS
+setting. This requires the use of SITEURL in the templates::
+
+ <link rel="stylesheet" href="{{ SITEURL }}/{{ ASSET_URL }}">
+
+.. _webassets: https://webassets.readthedocs.org/
+
+"""
+from __future__ import unicode_literals
+
+import os
+import logging
+
+from pelican import signals
+logger = logging.getLogger(__name__)
+
+try:
+ import webassets
+ from webassets import Environment
+ from webassets.ext.jinja2 import AssetsExtension
+except ImportError:
+ webassets = None
+
+def add_jinja2_ext(pelican):
+ """Add Webassets to Jinja2 extensions in Pelican settings."""
+
+ if 'JINJA_ENVIRONMENT' in pelican.settings: # pelican 3.7+
+ pelican.settings['JINJA_ENVIRONMENT']['extensions'].append(AssetsExtension)
+ else:
+ pelican.settings['JINJA_EXTENSIONS'].append(AssetsExtension)
+
+
+def create_assets_env(generator):
+ """Define the assets environment and pass it to the generator."""
+
+ theme_static_dir = generator.settings['THEME_STATIC_DIR']
+ assets_destination = os.path.join(generator.output_path, theme_static_dir)
+ generator.env.assets_environment = Environment(
+ assets_destination, theme_static_dir)
+
+ if 'ASSET_CONFIG' in generator.settings:
+ for item in generator.settings['ASSET_CONFIG']:
+ generator.env.assets_environment.config[item[0]] = item[1]
+
+ if 'ASSET_BUNDLES' in generator.settings:
+ for name, args, kwargs in generator.settings['ASSET_BUNDLES']:
+ generator.env.assets_environment.register(name, *args, **kwargs)
+
+ if 'ASSET_DEBUG' in generator.settings:
+ generator.env.assets_environment.debug = generator.settings['ASSET_DEBUG']
+ elif logging.getLevelName(logger.getEffectiveLevel()) == "DEBUG":
+ generator.env.assets_environment.debug = True
+
+ for path in (generator.settings['THEME_STATIC_PATHS'] +
+ generator.settings.get('ASSET_SOURCE_PATHS', [])):
+ full_path = os.path.join(generator.theme, path)
+ generator.env.assets_environment.append_path(full_path)
+
+
+def register():
+ """Plugin registration."""
+ if webassets:
+ signals.initialized.connect(add_jinja2_ext)
+ signals.generator_init.connect(create_assets_env)
+ else:
+ logger.warning('`assets` failed to load dependency `webassets`.'
+ '`assets` plugin not loaded.')
diff --git a/theme/plugins/photos/README.md b/theme/plugins/photos/README.md
new file mode 100644
index 0000000..411265f
--- /dev/null
+++ b/theme/plugins/photos/README.md
@@ -0,0 +1,291 @@
+# Photos
+
+Use Photos to add a photo or a gallery of photos to an article, or to include photos in the body text. Photos are kept separately, as an organized library of high resolution photos, and resized as needed.
+
+## How to install and configure
+
+The plug-in requires `Pillow`: the Python Imaging Library and optionally `Piexif`, whose installation are outside the scope of this document.
+
+The plug-in resizes the referred photos, and generates thumbnails for galleries and associated photos, based on the following configuration and default values:
+
+`PHOTO_LIBRARY = "~/Pictures"`
+: Absolute path to the folder where the original photos are kept, organized in sub-folders.
+
+`PHOTO_GALLERY = (1024, 768, 80)`
+: For photos in galleries, maximum width and height, plus JPEG quality as a percentage. This would typically be the size of the photo displayed when the reader clicks a thumbnail.
+
+`PHOTO_ARTICLE = (760, 506, 80)`
+: For photos associated with articles, maximum width, height, and quality. The maximum size would typically depend on the needs of the theme. 760px is suitable for the theme `notmyidea`.
+
+`PHOTO_THUMB = (192, 144, 60)`
+: For thumbnails, maximum width, height, and quality.
+
+`PHOTO_SQUARE_THUMB = False`
+: Crops thumbnails to make them square.
+
+`PHOTO_RESIZE_JOBS = 5`
+: Number of parallel resize jobs to be run. Defaults to 1.
+
+`PHOTO_WATERMARK = True`
+: Adds a watermark to all photos in articles and pages. Defaults to using your site name.
+
+`PHOTO_WATERMARK_TEXT' = SITENAME`
+: Allow the user to change the watermark text or remove it completely. By default it uses [SourceCodePro-Bold](http://www.adobe.com/products/type/font-information/source-code-pro-readme.html) as the font.
+
+`PHOTO_WATERMARK_IMG = ''`
+: Allows the user to add an image in addition to or as the only watermark. Set the variable to the location.
+
+**The following features require the piexif library**
+`PHOTO_EXIF_KEEP = True`
+: Keeps the exif of the input photo.
+
+`PHOTO_EXIF_REMOVE_GPS = True`
+: Removes any GPS information from the files exif data.
+
+`PHOTO_EXIF_COPYRIGHT = 'COPYRIGHT'`
+: Attaches an author and a license to the file. Choices include:
+ - `COPYRIGHT`: Copyright
+ - `CC0`: Public Domain
+ - `CC-BY-NC-ND`: Creative Commons Attribution-NonCommercial-NoDerivatives
+ - `CC-BY-NC-SA`: Creative Commons Attribution-NonCommercial-ShareAlike
+ - `CC-BY`: Creative Commons Attribution
+ - `CC-BY-SA`: Creative Commons Attribution-ShareAlike
+ - `CC-BY-NC`: Creative Commons Attribution-NonCommercial
+ - `CC-BY-ND`: Creative Commons Attribution-NoDerivatives
+
+`PHOTO_EXIF_COPYRIGHT_AUTHOR = 'Your Name Here'`
+: Adds an author name to the photo's exif and copyright statement. Defaults to `AUTHOR` value from the `pelicanconf.py`
+
+The plug-in automatically resizes the photos and publishes them to the following output folder:
+
+ ./output/photos
+
+**WARNING:** The plug-in can take hours to resize 40,000 photos, therefore, photos and thumbnails are only generated once. Clean the output folders to regenerate the resized photos again.
+
+## How to use
+
+Maintain an organized library of high resolution photos somewhere on disk, using folders to group related images. The default path `~/Pictures` is convenient for Mac OS X users.
+
+* To create a gallery of photos, add the metadata field `gallery: {photo}folder` to an article. To simplify the transition from the plug-in Gallery, the syntax `gallery: {filename}folder` is also accepted.
+* You can now have multiple galleries. The galleries need to be seperated by a comma in the metadata field. The syntax is gallery: `{photo}folder, {photo}folder2`. You can also add titles to your galleries. The syntax is: `{photo}folder, {photo}folder2{This is a title}`. Using the following example the first gallery would have the title of the folder location and the second would have the title `This is a tile.`
+* To use an image in the body of the text, just use the syntax `{photo}folder/image.jpg` instead of the usual `{filename}/images/image.jpg`.
+* To use an image in the body of the text, which can be used with [Lightbox](http://lokeshdhakar.com/projects/lightbox2/) just use the syntax `{lightbox}folder/image.jpg`. For use with other implementations, the gallery and caption attribute names can be set with `PHOTO_LIGHTBOX_GALLERY_ATTR` and `PHOTO_LIGHTBOX_CAPTION_ATTR`.
+* To associate an image with an article, add the metadata field `image: {photo}folder/image.jpg` to an article. Use associated images to improve navigation. For compatibility, the syntax `image: {filename}/images/image.jpg` is also accepted.
+
+### Exif, Captions, and Blacklists
+Folders of photos may optionally have three text files, where each line describes one photo. You can use the `#` to comment out a line. Generating these optional files is left as an exercise for the reader (but consider using Phil Harvey's [exiftool](http://www.sno.phy.queensu.ca/~phil/exiftool/)). See below for one method of extracting exif data.
+
+`exif.txt`
+: Associates compact technical information with photos, typically the camera settings. For example:
+
+ best.jpg: Canon EOS 5D Mark II - 20mm f/8 1/250s ISO 100
+ night.jpg: Canon EOS 5D Mark II - 47mm f/8 5s ISO 100
+ # new.jpg: Canon EOS 5D Mark II - 47mm f/8 5s ISO 100
+
+`captions.txt`
+: Associates comments with photos. For example:
+
+ best.jpg: My best photo ever! How lucky of me!
+ night.jpg: Twilight over the dam.
+ # new.jpg: My new photo blog entry is not quite ready.
+
+`blacklist.txt`
+: Skips photos the user does not want to include. For example:
+
+ this-file-will-be-skipped.jpg
+ this-one-will-be-skipped-too.jpg
+ # but-this-file-will-NOT-be-skipped.jpg
+ this-one-will-be-also-skipped.jpg
+
+
+Here is an example Markdown article that shows the four use cases:
+
+ title: My Article
+ gallery: {photo}favorite
+ image: {photo}favorite/best.jpg
+
+ Here are my best photos, taken with my favorite camera:
+ .
+ .
+
+The default behavior of the Photos plugin removes the exif information from the file. If you would like to keep the exif information, you can install the `piexif` library for python and add the following settings to keep some or all of the exif information. This feature is not a replacement for the `exif.txt` feature but in addition to that feature. This feature currently only works with jpeg input files.
+
+## How to change the Jinja templates
+
+The plugin provides the following variables to your templates:
+
+`article.photo_image`
+: For articles with an associated photo, a tuple with the following information:
+
+* The filename of the original photo.
+* The output path to the generated photo.
+* The output path to the generated thumbnail.
+
+For example, modify the template `article.html` as shown below to display the associated image before the article content:
+
+```html
+<div class="entry-content">
+ {% if article.photo_image %}<img src="{{ SITEURL }}/{{ article.photo_image[1] }}" />{% endif %}
+ {% include 'article_infos.html' %}
+ {{ article.content }}
+</div><!-- /.entry-content -->
+```
+
+`article.photo_gallery`
+: For articles with a gallery, a list of the photos in the gallery. Each item in the list is a tuple with five elements:
+
+* The title of the gallery
+* The filename of the original photo.
+* The output path to the generated photo.
+* The output path to the generated thumbnail.
+* The EXIF information of the photo, as read from the file `exif.txt`.
+* The caption of the photo, as read from `captions.txt`.
+
+For example, add the following to the template `article.html` to add the gallery as the end of the article:
+
+```html
+{% if article.photo_gallery %}
+<div class="gallery">
+ {% for title, gallery in article.photo_gallery %}
+ <h1>{{ title }}</h1>
+ {% for name, photo, thumb, exif, caption in gallery %}
+ <a href="{{ SITEURL }}/{{ photo }}" title="{{ name }}" exif="{{ exif }}" caption="{{ caption }}"><img src="{{ SITEURL }}/{{ thumb }}"></a>
+ {% endfor %}
+ {% endfor %}
+</div>
+{% endif %}
+```
+
+For example, add the following to the template `index.html`, inside the `entry-content`, to display the thumbnail with a link to the article:
+
+```html
+{% if article.photo_image %}<a href="{{ SITEURL }}/{{ article.url }}"><img src="{{ SITEURL }}/{{ article.photo_image[2] }}"
+ style="display: inline; float: right; margin: 2px 0 2ex 4ex;" /></a>
+{% endif %}
+```
+
+## How to make the gallery lightbox
+
+There are several JavaScript libraries that display a list of images as a lightbox. The example below uses [Magnific Popup](http://dimsemenov.com/plugins/magnific-popup/), which allows the more complex initialization needed to display both the filename, the compact technical information, and the caption. The solution would be simpler if photos did not show any extra information.
+
+Copy the files `magnific-popup.css` and `magnific-popup.js` to the root of your Pelican template.
+
+Add the following to the template `base.html`, inside the HTML `head` tags:
+
+```html
+{% if (article and article.photo_gallery) or (articles_page and articles_page.object_list[0].photo_gallery) %}
+ <link rel="stylesheet" href="{{ SITEURL }}/{{ THEME_STATIC_DIR }}/magnific-popup.css">
+{% endif %}
+```
+
+Add the following to the template `base.html`, before the closing HTML `</body>` tag:
+
+```JavaScript
+{% if (article and article.photo_gallery) or (articles_page and articles_page.object_list[0].photo_gallery) %}
+<!-- jQuery 1.7.2+ or Zepto.js 1.0+ -->
+<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
+
+<!-- Magnific Popup core JS file -->
+<script src="{{ SITEURL }}/{{ THEME_STATIC_DIR }}/magnific-popup.js"></script>
+<script>
+$('.gallery').magnificPopup({
+ delegate: 'a',
+ type: 'image',
+ gallery: {
+ enabled: true,
+ navigateByImgClick: true,
+ preload: [1,2]
+ },
+ image: {
+ titleSrc: function(item) {
+ if (item.el.attr('caption') && item.el.attr('exif')) {
+ return (item.el.attr('caption').replace(/\\n/g, '<br />') +
+ '<small>' + item.el.attr('title') + ' - ' + item.el.attr('exif') + '</small>');
+ }
+ return item.el.attr('title') + '<small>' + item.el.attr('exif') + '</small>';
+ } }
+});
+</script>
+{% endif %}
+```
+
+## How to make a Bootstrap Carousel
+
+If you are using bootstrap, the following code is an example of how one could create a carousel.
+
+```html
+{% if article.photo_gallery %}
+ {% for title, gallery in article.photo_gallery %}
+ <h1>{{ title }}</h1>
+ <div id="carousel-{{ loop.index }}" class="carousel slide">
+ <ol class="carousel-indicators">
+ {% for i in range(0, gallery|length) %}
+ <li data-target="#carousel-{{ loop.index }}" data-slide-to="{{ i }}" {% if i==0 %} class="active" {% endif %}></li>
+ {% endfor %}
+ </ol>
+ <div class="carousel-inner">
+ {% for name, photo, thumb, exif, caption in gallery %}
+ {% if loop.first %}
+ <div class="item active">
+ {% else %}
+ <div class="item">
+ {% endif %}
+ <img src="{{ SITEURL }}/{{ photo }}" exif="{{ exif }}" alt="{{ caption }}">
+ <div class="carousel-caption">
+ <h5>{{ caption }}</h5>
+ </div> <!-- carousel-caption -->
+ </div> <!-- item -->
+ {% endfor %}
+ </div> <!-- carousel-inner -->
+ <a class="left carousel-control" href="#carousel-{{ loop.index }}" data-slide="prev">
+ <span class="glyphicon glyphicon-chevron-left"></span>
+ </a>
+ <a class="right carousel-control" href="#carousel-{{ loop.index }}" data-slide="next">
+ <span class="glyphicon glyphicon-chevron-right"></span>
+ </a>
+ </div> <!-- closes carousel-{{ loop.index }} -->
+ {% endfor %}
+{% endif %}
+```
+
+## Exiftool example
+
+You can add the following stanza to your fab file if you are using `fabric` to generate the appropriate text files for your galleries. You need to set the location of `Exiftool` control files.
+
+```Python
+def photo_gallery_gen(location):
+ """Create gallery metadata files."""
+ local_path = os.getcwd() + 'LOCATION OF YOUR EXIF CONTROL FILES'
+ with lcd(location):
+ local("exiftool -p {fmt_path}/exif.fmt . > exif.txt".format(
+ fmt_path=local_path))
+ local("exiftool -p {fmt_path}/captions.fmt . > captions.txt".format(
+ fmt_path=local_path))
+
+```
+
+`captions.fmt` example file
+
+```
+$FileName: $Description
+```
+
+`exif.fmt` example file
+
+```
+$FileName: $CreateDate - $Make $Model Stats:(f/$Aperture, ${ShutterSpeed}s, ISO $ISO Flash: $Flash) GPS:($GPSPosition $GPSAltitude)
+```
+
+## Known use cases
+
+[pxquim.pt](http://pxquim.pt/) uses Photos and the plug-in Sub-parts to publish 600 photo galleries with 40,000 photos. Photos keeps the high-resolution photos separate from the site articles.
+
+[pxquim.com](http://pxquim.com/) uses sub-parts to cover conferences, where it makes sense to have a sub-part for each speaker.
+
+## Alternatives
+
+Gallery
+: Galleries are distinct entities, without the organizational capabilities of articles. Photos must be resized separately, and must be kept with the source of the blog. Gallery was the initial inspiration for Photos.
+
+Image_process
+: Resize and process images in the article body in a more flexible way (based on the CSS class of the image), but without the ability to create galleries. The source photos must be kept with the source of the blog.
diff --git a/theme/plugins/photos/SourceCodePro-Bold.otf b/theme/plugins/photos/SourceCodePro-Bold.otf
new file mode 100644
index 0000000..f4e576c
--- /dev/null
+++ b/theme/plugins/photos/SourceCodePro-Bold.otf
Binary files differ
diff --git a/theme/plugins/photos/SourceCodePro-Regular.otf b/theme/plugins/photos/SourceCodePro-Regular.otf
new file mode 100644
index 0000000..4e3b9d0
--- /dev/null
+++ b/theme/plugins/photos/SourceCodePro-Regular.otf
Binary files differ
diff --git a/theme/plugins/photos/__init__.py b/theme/plugins/photos/__init__.py
new file mode 100644
index 0000000..9832420
--- /dev/null
+++ b/theme/plugins/photos/__init__.py
@@ -0,0 +1 @@
+from .photos import *
diff --git a/theme/plugins/photos/licenses.json b/theme/plugins/photos/licenses.json
new file mode 100644
index 0000000..c727e21
--- /dev/null
+++ b/theme/plugins/photos/licenses.json
@@ -0,0 +1,30 @@
+{
+ "CC-BY-NC-ND": {
+ "URL": "https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ },
+ "CC-BY-NC-SA": {
+ "URL": "https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ },
+ "CC-BY": {
+ "URL": "https://creativecommons.org/licenses/by/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ },
+ "CC-BY-SA": {
+ "URL": "https://creativecommons.org/licenses/by-sa/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution-ShareAlike 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ },
+ "CC0": {
+ "URL": "https://creativecommons.org/publicdomain/zero/1.0/",
+ "Text": "CC0 Copyleft license, {Author} {Year}. To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. This software is distributed without any warranty. See {URL} for more information."
+ },
+ "CC-BY-NC": {
+ "URL": "https://creativecommons.org/licenses/by-nc/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution-NonCommercial 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ },
+ "CC-BY-ND": {
+ "URL": "https://creativecommons.org/licenses/by-nd/4.0/legalcode",
+ "Text": "Copyleft license, {Author} {Year}. Content licensed under a Creative Commons Attribution-NoDerivatives 4.0 International License, except where indicated otherwise. See {URL} for more information."
+ }
+}
diff --git a/theme/plugins/photos/photos.py b/theme/plugins/photos/photos.py
new file mode 100644
index 0000000..d0c1af1
--- /dev/null
+++ b/theme/plugins/photos/photos.py
@@ -0,0 +1,589 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import datetime
+import itertools
+import json
+import logging
+import multiprocessing
+import os
+import pprint
+import re
+import sys
+
+from pelican.generators import ArticlesGenerator
+from pelican.generators import PagesGenerator
+from pelican.settings import DEFAULT_CONFIG
+from pelican import signals
+from pelican.utils import pelican_open
+
+logger = logging.getLogger(__name__)
+
+try:
+ from PIL import Image
+ from PIL import ImageDraw
+ from PIL import ImageEnhance
+ from PIL import ImageFont
+ from PIL import ImageOps
+except ImportError:
+ logger.error('PIL/Pillow not found')
+
+try:
+ import piexif
+except ImportError:
+ ispiexif = False
+ logger.warning('piexif not found! Cannot use exif manipulation features')
+else:
+ ispiexif = True
+ logger.debug('piexif found.')
+
+
+def initialized(pelican):
+ p = os.path.expanduser('~/Pictures')
+
+ DEFAULT_CONFIG.setdefault('PHOTO_LIBRARY', p)
+ DEFAULT_CONFIG.setdefault('PHOTO_GALLERY', (1024, 768, 80))
+ DEFAULT_CONFIG.setdefault('PHOTO_ARTICLE', (760, 506, 80))
+ DEFAULT_CONFIG.setdefault('PHOTO_THUMB', (192, 144, 60))
+ DEFAULT_CONFIG.setdefault('PHOTO_SQUARE_THUMB', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_GALLERY_TITLE', '')
+ DEFAULT_CONFIG.setdefault('PHOTO_ALPHA_BACKGROUND_COLOR', (255, 255, 255))
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_THUMB', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_TEXT', DEFAULT_CONFIG['SITENAME'])
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_TEXT_COLOR', (255, 255, 255))
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG', '')
+ DEFAULT_CONFIG.setdefault('PHOTO_WATERMARK_IMG_SIZE', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_RESIZE_JOBS', 1)
+ DEFAULT_CONFIG.setdefault('PHOTO_EXIF_KEEP', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_EXIF_AUTOROTATE', True)
+ DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT', False)
+ DEFAULT_CONFIG.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', DEFAULT_CONFIG['SITENAME'])
+ DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
+ DEFAULT_CONFIG.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+
+ DEFAULT_CONFIG['queue_resize'] = {}
+ DEFAULT_CONFIG['created_galleries'] = {}
+ DEFAULT_CONFIG['plugin_dir'] = os.path.dirname(os.path.realpath(__file__))
+
+ if pelican:
+ pelican.settings.setdefault('PHOTO_LIBRARY', p)
+ pelican.settings.setdefault('PHOTO_GALLERY', (1024, 768, 80))
+ pelican.settings.setdefault('PHOTO_ARTICLE', (760, 506, 80))
+ pelican.settings.setdefault('PHOTO_THUMB', (192, 144, 60))
+ pelican.settings.setdefault('PHOTO_SQUARE_THUMB', False)
+ pelican.settings.setdefault('PHOTO_GALLERY_TITLE', '')
+ pelican.settings.setdefault('PHOTO_ALPHA_BACKGROUND_COLOR', (255, 255, 255))
+ pelican.settings.setdefault('PHOTO_WATERMARK', False)
+ pelican.settings.setdefault('PHOTO_WATERMARK_THUMB', False)
+ pelican.settings.setdefault('PHOTO_WATERMARK_TEXT', pelican.settings['SITENAME'])
+ pelican.settings.setdefault('PHOTO_WATERMARK_TEXT_COLOR', (255, 255, 255))
+ pelican.settings.setdefault('PHOTO_WATERMARK_IMG', '')
+ pelican.settings.setdefault('PHOTO_WATERMARK_IMG_SIZE', False)
+ pelican.settings.setdefault('PHOTO_RESIZE_JOBS', 1)
+ pelican.settings.setdefault('PHOTO_EXIF_KEEP', False)
+ pelican.settings.setdefault('PHOTO_EXIF_REMOVE_GPS', False)
+ pelican.settings.setdefault('PHOTO_EXIF_AUTOROTATE', True)
+ pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT', False)
+ pelican.settings.setdefault('PHOTO_EXIF_COPYRIGHT_AUTHOR', pelican.settings['AUTHOR'])
+ pelican.settings.setdefault('PHOTO_LIGHTBOX_GALLERY_ATTR', 'data-lightbox')
+ pelican.settings.setdefault('PHOTO_LIGHTBOX_CAPTION_ATTR', 'data-title')
+
+
+def read_notes(filename, msg=None):
+ notes = {}
+ try:
+ with pelican_open(filename) as text:
+ for line in text.splitlines():
+ if line.startswith('#'):
+ continue
+ m = line.split(':', 1)
+ if len(m) > 1:
+ pic = m[0].strip()
+ note = m[1].strip()
+ if pic and note:
+ notes[pic] = note
+ else:
+ notes[line] = ''
+ except Exception as e:
+ if msg:
+ logger.info('{} at file {}'.format(msg, filename))
+ logger.debug('read_notes issue: {} at file {}. Debug message:{}'.format(msg, filename, e))
+ return notes
+
+
+def enqueue_resize(orig, resized, spec=(640, 480, 80)):
+ if resized not in DEFAULT_CONFIG['queue_resize']:
+ DEFAULT_CONFIG['queue_resize'][resized] = (orig, spec)
+ elif DEFAULT_CONFIG['queue_resize'][resized] != (orig, spec):
+ logger.error('photos: resize conflict for {}, {}-{} is not {}-{}'.format(resized, DEFAULT_CONFIG['queue_resize'][resized][0], DEFAULT_CONFIG['queue_resize'][resized][1], orig, spec))
+
+
+def isalpha(img):
+ return True if img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) else False
+
+
+def remove_alpha(img, bg_color):
+ background = Image.new("RGB", img.size, bg_color)
+ background.paste(img, mask=img.split()[3]) # 3 is the alpha channel
+ return background
+
+
+def ReduceOpacity(im, opacity):
+ """Reduces Opacity.
+
+ Returns an image with reduced opacity.
+ Taken from http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362879
+ """
+ assert opacity >= 0 and opacity <= 1
+ if isalpha(im):
+ im = im.copy()
+ else:
+ im = im.convert('RGBA')
+
+ alpha = im.split()[3]
+ alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
+ im.putalpha(alpha)
+ return im
+
+
+def watermark_photo(image, settings):
+ margin = [10, 10]
+ opacity = 0.6
+
+ watermark_layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
+ draw_watermark = ImageDraw.Draw(watermark_layer)
+ text_reducer = 32
+ image_reducer = 8
+ text_size = [0, 0]
+ mark_size = [0, 0]
+ text_position = [0, 0]
+
+ if settings['PHOTO_WATERMARK_TEXT']:
+ font_name = 'SourceCodePro-Bold.otf'
+ default_font = os.path.join(DEFAULT_CONFIG['plugin_dir'], font_name)
+ font = ImageFont.FreeTypeFont(default_font, watermark_layer.size[0] // text_reducer)
+ text_size = draw_watermark.textsize(settings['PHOTO_WATERMARK_TEXT'], font)
+ text_position = [image.size[i] - text_size[i] - margin[i] for i in [0, 1]]
+ draw_watermark.text(text_position, settings['PHOTO_WATERMARK_TEXT'], settings['PHOTO_WATERMARK_TEXT_COLOR'], font=font)
+
+ if settings['PHOTO_WATERMARK_IMG']:
+ mark_image = Image.open(settings['PHOTO_WATERMARK_IMG'])
+ mark_image_size = [watermark_layer.size[0] // image_reducer for size in mark_size]
+ mark_image_size = settings['PHOTO_WATERMARK_IMG_SIZE'] if settings['PHOTO_WATERMARK_IMG_SIZE'] else mark_image_size
+ mark_image.thumbnail(mark_image_size, Image.ANTIALIAS)
+ mark_position = [watermark_layer.size[i] - mark_image.size[i] - margin[i] for i in [0, 1]]
+ mark_position = tuple([mark_position[0] - (text_size[0] // 2) + (mark_image_size[0] // 2), mark_position[1] - text_size[1]])
+
+ if not isalpha(mark_image):
+ mark_image = mark_image.convert('RGBA')
+
+ watermark_layer.paste(mark_image, mark_position, mark_image)
+
+ watermark_layer = ReduceOpacity(watermark_layer, opacity)
+ image.paste(watermark_layer, (0, 0), watermark_layer)
+
+ return image
+
+
+def rotate_image(img, exif_dict):
+ if "exif" in img.info and piexif.ImageIFD.Orientation in exif_dict["0th"]:
+ orientation = exif_dict["0th"].pop(piexif.ImageIFD.Orientation)
+ if orientation == 2:
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
+ elif orientation == 3:
+ img = img.rotate(180)
+ elif orientation == 4:
+ img = img.rotate(180).transpose(Image.FLIP_LEFT_RIGHT)
+ elif orientation == 5:
+ img = img.rotate(-90).transpose(Image.FLIP_LEFT_RIGHT)
+ elif orientation == 6:
+ img = img.rotate(-90, expand=True)
+ elif orientation == 7:
+ img = img.rotate(90).transpose(Image.FLIP_LEFT_RIGHT)
+ elif orientation == 8:
+ img = img.rotate(90)
+
+ return (img, exif_dict)
+
+
+def build_license(license, author):
+ year = datetime.datetime.now().year
+ license_file = os.path.join(DEFAULT_CONFIG['plugin_dir'], 'licenses.json')
+
+ with open(license_file) as data_file:
+ licenses = json.load(data_file)
+
+ if any(license in k for k in licenses):
+ return licenses[license]['Text'].format(Author=author, Year=year, URL=licenses[license]['URL'])
+ else:
+ return 'Copyright {Year} {Author}, All Rights Reserved'.format(Author=author, Year=year)
+
+
+def manipulate_exif(img, settings):
+ try:
+ exif = piexif.load(img.info['exif'])
+ except Exception:
+ logger.debug('EXIF information not found')
+ exif = {}
+
+ if settings['PHOTO_EXIF_AUTOROTATE']:
+ img, exif = rotate_image(img, exif)
+
+ if settings['PHOTO_EXIF_REMOVE_GPS']:
+ exif.pop('GPS')
+
+ if settings['PHOTO_EXIF_COPYRIGHT']:
+
+ # We want to be minimally destructive to any preset exif author or copyright information.
+ # If there is copyright or author information prefer that over everything else.
+ if not exif['0th'].get(piexif.ImageIFD.Artist):
+ exif['0th'][piexif.ImageIFD.Artist] = settings['PHOTO_EXIF_COPYRIGHT_AUTHOR']
+ author = settings['PHOTO_EXIF_COPYRIGHT_AUTHOR']
+
+ if not exif['0th'].get(piexif.ImageIFD.Copyright):
+ license = build_license(settings['PHOTO_EXIF_COPYRIGHT'], author)
+ exif['0th'][piexif.ImageIFD.Copyright] = license
+
+ return (img, piexif.dump(exif))
+
+
+def resize_worker(orig, resized, spec, settings):
+ logger.info('photos: make photo {} -> {}'.format(orig, resized))
+ im = Image.open(orig)
+
+ if ispiexif and settings['PHOTO_EXIF_KEEP'] and im.format == 'JPEG': # Only works with JPEG exif for sure.
+ try:
+ im, exif_copy = manipulate_exif(im, settings)
+ except:
+ logger.info('photos: no EXIF or EXIF error in {}'.format(orig))
+ exif_copy = b''
+ else:
+ exif_copy = b''
+
+ icc_profile = im.info.get("icc_profile", None)
+
+ if settings['PHOTO_SQUARE_THUMB'] and spec == settings['PHOTO_THUMB']:
+ im = ImageOps.fit(im, (spec[0], spec[1]), Image.ANTIALIAS)
+
+ im.thumbnail((spec[0], spec[1]), Image.ANTIALIAS)
+ directory = os.path.split(resized)[0]
+
+ if isalpha(im):
+ im = remove_alpha(im, settings['PHOTO_ALPHA_BACKGROUND_COLOR'])
+
+ if not os.path.exists(directory):
+ try:
+ os.makedirs(directory)
+ except Exception:
+ logger.exception('Could not create {}'.format(directory))
+ else:
+ logger.debug('Directory already exists at {}'.format(os.path.split(resized)[0]))
+
+ if settings['PHOTO_WATERMARK']:
+ isthumb = True if spec == settings['PHOTO_THUMB'] else False
+ if not isthumb or (isthumb and settings['PHOTO_WATERMARK_THUMB']):
+ im = watermark_photo(im, settings)
+
+ im.save(resized, 'JPEG', quality=spec[2], icc_profile=icc_profile, exif=exif_copy)
+
+
+def resize_photos(generator, writer):
+ if generator.settings['PHOTO_RESIZE_JOBS'] == -1:
+ debug = True
+ generator.settings['PHOTO_RESIZE_JOBS'] = 1
+ else:
+ debug = False
+
+ pool = multiprocessing.Pool(generator.settings['PHOTO_RESIZE_JOBS'])
+ logger.debug('Debug Status: {}'.format(debug))
+ for resized, what in DEFAULT_CONFIG['queue_resize'].items():
+ resized = os.path.join(generator.output_path, resized)
+ orig, spec = what
+ if (not os.path.isfile(resized) or os.path.getmtime(orig) > os.path.getmtime(resized)):
+ if debug:
+ resize_worker(orig, resized, spec, generator.settings)
+ else:
+ pool.apply_async(resize_worker, (orig, resized, spec, generator.settings))
+
+ pool.close()
+ pool.join()
+
+
+def detect_content(content):
+ hrefs = None
+
+ def replacer(m):
+ what = m.group('what')
+ value = m.group('value')
+ tag = m.group('tag')
+ output = m.group(0)
+
+ if what in ('photo', 'lightbox'):
+ if value.startswith('/'):
+ value = value[1:]
+
+ path = os.path.join(
+ os.path.expanduser(settings['PHOTO_LIBRARY']),
+ value
+ )
+
+ if os.path.isfile(path):
+ photo_prefix = os.path.splitext(value)[0].lower()
+
+ if what == 'photo':
+ photo_article = photo_prefix + 'a.jpg'
+ enqueue_resize(
+ path,
+ os.path.join('photos', photo_article),
+ settings['PHOTO_ARTICLE']
+ )
+
+ output = ''.join((
+ '<',
+ m.group('tag'),
+ m.group('attrs_before'),
+ m.group('src'),
+ '=',
+ m.group('quote'),
+ os.path.join(settings['SITEURL'], 'photos', photo_article),
+ m.group('quote'),
+ m.group('attrs_after'),
+ ))
+
+ elif what == 'lightbox' and tag == 'img':
+ photo_gallery = photo_prefix + '.jpg'
+ enqueue_resize(
+ path,
+ os.path.join('photos', photo_gallery),
+ settings['PHOTO_GALLERY']
+ )
+
+ photo_thumb = photo_prefix + 't.jpg'
+ enqueue_resize(
+ path,
+ os.path.join('photos', photo_thumb),
+ settings['PHOTO_THUMB']
+ )
+
+ lightbox_attr_list = ['']
+
+ gallery_name = value.split('/')[0]
+ lightbox_attr_list.append('{}="{}"'.format(
+ settings['PHOTO_LIGHTBOX_GALLERY_ATTR'],
+ gallery_name
+ ))
+
+ captions = read_notes(
+ os.path.join(os.path.dirname(path), 'captions.txt'),
+ msg = 'photos: No captions for gallery'
+ )
+ caption = captions.get(os.path.basename(path)) if captions else None
+ if caption:
+ lightbox_attr_list.append('{}="{}"'.format(
+ settings['PHOTO_LIGHTBOX_CAPTION_ATTR'],
+ caption
+ ))
+
+ lightbox_attrs = ' '.join(lightbox_attr_list)
+
+ output = ''.join((
+ '<a href=',
+ m.group('quote'),
+ os.path.join(settings['SITEURL'], 'photos', photo_gallery),
+ m.group('quote'),
+ lightbox_attrs,
+ '><img',
+ m.group('attrs_before'),
+ 'src=',
+ m.group('quote'),
+ os.path.join(settings['SITEURL'], 'photos', photo_thumb),
+ m.group('quote'),
+ m.group('attrs_after'),
+ '</a>'
+ ))
+
+ else:
+ logger.error('photos: No photo %s', path)
+
+ return output
+
+ if hrefs is None:
+ regex = r"""
+ <\s*
+ (?P<tag>[^\s\>]+) # detect the tag
+ (?P<attrs_before>[^\>]*)
+ (?P<src>href|src) # match tag with src and href attr
+ \s*=
+ (?P<quote>["\']) # require value to be quoted
+ (?P<path>{0}(?P<value>.*?)) # the url value
+ (?P=quote)
+ (?P<attrs_after>[^\>]*>)
+ """.format(
+ content.settings['INTRASITE_LINK_REGEX']
+ )
+ hrefs = re.compile(regex, re.X)
+
+ if content._content and ('{photo}' in content._content or '{lightbox}' in content._content):
+ settings = content.settings
+ content._content = hrefs.sub(replacer, content._content)
+
+
+def galleries_string_decompose(gallery_string):
+ splitter_regex = re.compile(r'[\s,]*?({photo}|{filename})')
+ title_regex = re.compile(r'{(.+)}')
+ galleries = map(unicode.strip if sys.version_info.major == 2 else str.strip, filter(None, splitter_regex.split(gallery_string)))
+ galleries = [gallery[1:] if gallery.startswith('/') else gallery for gallery in galleries]
+ if len(galleries) % 2 == 0 and ' ' not in galleries:
+ galleries = zip(zip(['type'] * len(galleries[0::2]), galleries[0::2]), zip(['location'] * len(galleries[0::2]), galleries[1::2]))
+ galleries = [dict(gallery) for gallery in galleries]
+ for gallery in galleries:
+ title = re.search(title_regex, gallery['location'])
+ if title:
+ gallery['title'] = title.group(1)
+ gallery['location'] = re.sub(title_regex, '', gallery['location']).strip()
+ else:
+ gallery['title'] = DEFAULT_CONFIG['PHOTO_GALLERY_TITLE']
+ return galleries
+ else:
+ logger.error('Unexpected gallery location format! \n{}'.format(pprint.pformat(galleries)))
+
+
+def process_gallery(generator, content, location):
+ content.photo_gallery = []
+
+ galleries = galleries_string_decompose(location)
+
+ for gallery in galleries:
+
+ if gallery['location'] in DEFAULT_CONFIG['created_galleries']:
+ content.photo_gallery.append((gallery['location'], DEFAULT_CONFIG['created_galleries'][gallery]))
+ continue
+
+ if gallery['type'] == '{photo}':
+ dir_gallery = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), gallery['location'])
+ rel_gallery = gallery['location']
+ elif gallery['type'] == '{filename}':
+ base_path = os.path.join(generator.path, content.relative_dir)
+ dir_gallery = os.path.join(base_path, gallery['location'])
+ rel_gallery = os.path.join(content.relative_dir, gallery['location'])
+
+ if os.path.isdir(dir_gallery):
+ logger.info('photos: Gallery detected: {}'.format(rel_gallery))
+ dir_photo = os.path.join('photos', rel_gallery.lower())
+ dir_thumb = os.path.join('photos', rel_gallery.lower())
+ exifs = read_notes(os.path.join(dir_gallery, 'exif.txt'),
+ msg='photos: No EXIF for gallery')
+ captions = read_notes(os.path.join(dir_gallery, 'captions.txt'), msg='photos: No captions for gallery')
+ blacklist = read_notes(os.path.join(dir_gallery, 'blacklist.txt'), msg='photos: No blacklist for gallery')
+ content_gallery = []
+
+ title = gallery['title']
+ for pic in sorted(os.listdir(dir_gallery)):
+ if pic.startswith('.'):
+ continue
+ if pic.endswith('.txt'):
+ continue
+ if pic in blacklist:
+ continue
+ photo = os.path.splitext(pic)[0].lower() + '.jpg'
+ thumb = os.path.splitext(pic)[0].lower() + 't.jpg'
+ content_gallery.append((
+ pic,
+ os.path.join(dir_photo, photo),
+ os.path.join(dir_thumb, thumb),
+ exifs.get(pic, ''),
+ captions.get(pic, '')))
+
+ enqueue_resize(
+ os.path.join(dir_gallery, pic),
+ os.path.join(dir_photo, photo),
+ generator.settings['PHOTO_GALLERY'])
+ enqueue_resize(
+ os.path.join(dir_gallery, pic),
+ os.path.join(dir_thumb, thumb),
+ generator.settings['PHOTO_THUMB'])
+
+ content.photo_gallery.append((title, content_gallery))
+ logger.debug('Gallery Data: '.format(pprint.pformat(content.photo_gallery)))
+ DEFAULT_CONFIG['created_galleries']['gallery'] = content_gallery
+ else:
+ logger.error('photos: Gallery does not exist: {} at {}'.format(gallery['location'], dir_gallery))
+
+
+def detect_gallery(generator, content):
+ if 'gallery' in content.metadata:
+ gallery = content.metadata.get('gallery')
+ if gallery.startswith('{photo}') or gallery.startswith('{filename}'):
+ process_gallery(generator, content, gallery)
+ elif gallery:
+ logger.error('photos: Gallery tag not recognized: {}'.format(gallery))
+
+
+def image_clipper(x):
+ return x[8:] if x[8] == '/' else x[7:]
+
+
+def file_clipper(x):
+ return x[11:] if x[10] == '/' else x[10:]
+
+
+def process_image(generator, content, image):
+ if image.startswith('{photo}'):
+ path = os.path.join(os.path.expanduser(generator.settings['PHOTO_LIBRARY']), image_clipper(image))
+ image = image_clipper(image)
+ elif image.startswith('{filename}'):
+ path = os.path.join(generator.path, content.relative_dir, file_clipper(image))
+ image = file_clipper(image)
+
+ if os.path.isfile(path):
+ photo = os.path.splitext(image)[0].lower() + 'a.jpg'
+ thumb = os.path.splitext(image)[0].lower() + 't.jpg'
+ content.photo_image = (
+ os.path.basename(image).lower(),
+ os.path.join('photos', photo),
+ os.path.join('photos', thumb))
+ enqueue_resize(
+ path,
+ os.path.join('photos', photo),
+ generator.settings['PHOTO_ARTICLE'])
+ enqueue_resize(
+ path,
+ os.path.join('photos', thumb),
+ generator.settings['PHOTO_THUMB'])
+ else:
+ logger.error('photo: No photo for {} at {}'.format(content.source_path, path))
+
+
+def detect_image(generator, content):
+ image = content.metadata.get('image', None)
+ if image:
+ if image.startswith('{photo}') or image.startswith('{filename}'):
+ process_image(generator, content, image)
+ else:
+ logger.error('photos: Image tag not recognized: {}'.format(image))
+
+
+def detect_images_and_galleries(generators):
+ """Runs generator on both pages and articles."""
+ for generator in generators:
+ if isinstance(generator, ArticlesGenerator):
+ for article in itertools.chain(generator.articles, generator.translations, generator.drafts):
+ detect_image(generator, article)
+ detect_gallery(generator, article)
+ elif isinstance(generator, PagesGenerator):
+ for page in itertools.chain(generator.pages, generator.translations, generator.hidden_pages):
+ detect_image(generator, page)
+ detect_gallery(generator, page)
+
+
+def register():
+ """Uses the new style of registration based on GitHub Pelican issue #314."""
+ signals.initialized.connect(initialized)
+ try:
+ signals.content_object_init.connect(detect_content)
+ signals.all_generators_finalized.connect(detect_images_and_galleries)
+ signals.article_writer_finalized.connect(resize_photos)
+ except Exception as e:
+ logger.exception('Plugin failed to execute: {}'.format(pprint.pformat(e)))
diff --git a/theme/requirements.txt b/theme/requirements.txt
new file mode 100644
index 0000000..b6f5391
--- /dev/null
+++ b/theme/requirements.txt
@@ -0,0 +1,7 @@
+cssmin==0.2.0
+ply==3.11
+pyScss==1.3.5
+slimit==0.8.1
+webassets==0.12.1
+Pillow
+piexif>=1.0.5
diff --git a/theme/static/css/pygments.css b/theme/static/css/pygments.css
new file mode 100644
index 0000000..ffc1611
--- /dev/null
+++ b/theme/static/css/pygments.css
@@ -0,0 +1,94 @@
+@charset "UTF-8";
+
+/* See available classes at https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/token.py */
+
+.hll { background-color: #ffd800; display: block;}
+.w { color: #bbbbbb; }
+/* .esc (Escape) */
+.err { color: #a61717; background-color: #e3d2d2; }
+/* .x (Other) */
+
+/* Keyword */
+.k { font-weight: bold; }
+.kc { font-weight: bold; }
+.kd { font-weight: bold; }
+.kn { font-weight: bold; }
+.kp { font-weight: bold; }
+.kr { font-weight: bold; }
+.kt { color: #445588; font-weight: bold; }
+
+/* Name */
+.n { color: #333333; }
+.na { color: teal; }
+.nb { color: #0086b3; }
+.bp { color: #1d3e81 }
+.nc { color: #445588; font-weight: bold; }
+.no { color: teal; }
+.nd { color: #3c5d5d; font-weight: bold }
+.ni { color: purple; }
+.ne { color: #990000; font-weight: bold; }
+.nf { color: #990000; font-weight: bold; }
+/* .py (Name.Property) */
+.nl { color: #990000; font-weight: bold }
+.nn { color: #555555; }
+/* .nx (Name.Other) */
+.nt { color: navy; }
+.nv { color: teal; }
+.vc { color: teal; }
+.vg { color: teal; }
+.vi { color: teal; }
+
+/* Literal */
+/* .l (Literal) */
+/* .ld (Literal.Date) */
+
+/* String */
+.s { color: #dd1144; }
+.sb { color: #dd1144; }
+.sc { color: #dd1144; }
+.sd { color: #dd1144; }
+.s2 { color: #dd1144; }
+.se { color: #dd1144; }
+.sh { color: #dd1144; }
+.si { color: #dd1144; }
+.sx { color: #dd1144; }
+.sr { color: #009926; }
+.s1 { color: #dd1144; }
+.ss { color: #990073; }
+
+/* Number */
+.m { color: #009999; }
+/* .mb (Number.Bin) */
+.mf { color: #009999; }
+.mh { color: #009999; }
+.mi { color: #009999; }
+.il { color: #009999; }
+.mo { color: #009999; }
+
+/* Operator */
+.o { font-weight: bold; }
+.ow { font-weight: bold; }
+
+/* .p (Punctuation) */
+
+/* Comment */
+.c { color: #999988; font-style: italic; }
+.cm { color: #999988; font-style: italic; }
+.cp { color: #999999; font-weight: bold; font-style: italic; }
+.c1 { color: #999988; font-style: italic; }
+.cs { color: #999999; font-weight: bold; font-style: italic; }
+
+/* Generic */
+/* .g (Generic) */
+.gd { color: #000000; background-color: #ffdddd; }
+.gd .x { color: #000000; background-color: #ffaaaa; }
+.ge { font-style: italic; }
+.gr { color: #aa0000; }
+.gh { color: #999999; }
+.gi { color: #000000; background-color: #ddffdd; }
+.gi .x { color: #000000; background-color: #aaffaa; }
+.go { color: #888888; }
+.gp { color: #555555; }
+.gs { font-weight: bold; }
+.gu { color: #800080; font-weight: bold; }
+.gt { color: #aa0000; }
diff --git a/theme/static/css/style.scss b/theme/static/css/style.scss
new file mode 100644
index 0000000..21212cb
--- /dev/null
+++ b/theme/static/css/style.scss
@@ -0,0 +1,374 @@
+@charset "UTF-8";
+
+$black: #000000;
+$white: #ffffff;
+$light-grey: #eeeeee;
+$med-grey: #5f5f5f;
+$dark-grey: #202020;
+$bg-grey: #FAFAFA;
+
+$code-border: #c7c7c7;
+$code-background: #f6f6f6;
+$code-linebg: #dddddd;
+
+$accent: #0e94ec;
+$danger: red;
+
+$sans: -apple-system, ".SFNSText-Regular", "San Francisco", "Roboto", "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif;
+$mono: monospace;
+
+$border_color: #c2c2c2;
+$pag_label_size: 60px;
+$logo_size: 150px;
+
+$content_size: 640px;
+$horiz_pad: 40px;
+
+$main_font_size: 1rem;
+$nav_font_size: 1rem;
+$subtitle_font_size: 0.9rem;
+$code_font_size: 1rem;
+$article_meta_font_size: 0.8rem;
+$pagination_font_size: 0.9rem;
+$footer_font_size: 0.7rem;
+
+//The minimum size the screen can be without scaling the main content
+$min_screen: floor((($content_size + $horiz_pad * 2) * 1.33));
+
+body {
+ margin: 0;
+ padding: 0;
+ background-color: $bg-grey;
+ color: $dark-grey;
+ font-family: $sans;
+ font-size: $main_font_size;
+ min-width: 300px;
+}
+
+a {
+ color: $black;
+ text-decoration: none;
+}
+
+a[href]:not(.nohover) {
+ &:hover{
+ color: $accent;
+ }
+}
+
+hr {
+ background-color: $border_color;
+ height: 1px;
+ border: none;
+}
+
+img {
+ max-width: 100%;
+}
+
+aside {
+ div#user_meta {
+ padding: $horiz_pad 20px $horiz_pad 20px;
+ text-align: center;
+
+ img#logo {
+ width: $logo_size;
+ height: $logo_size;
+ border-radius: 50%;
+ border: 5px solid $black;
+ background-color: $black;
+ box-shadow: 0px 0px 1px $black;
+ }
+
+ h2 {
+ margin: 15px 0 5px 0;
+ }
+
+ p {
+ margin: 0px;
+ font-size: $subtitle_font_size;
+ }
+
+ i {
+ width: 30px;
+ text-align: center;
+ vertical-align: middle;
+ }
+
+ div.social {
+ margin-top:15px;
+
+ a {
+ display: inline-block;
+ height: 30px;
+ }
+ }
+
+ ul {
+ margin: 0px;
+ padding-left: 0px;
+ list-style-type: none;
+
+ li {
+ line-height: 2em;
+
+ a {
+ i {
+ padding-left: 5px;
+ }
+ }
+ }
+ }
+ }
+}
+
+main {
+ position: relative;
+ width: 100%;
+ margin: auto;
+
+ header {
+ padding: 0px $horiz_pad 0px $horiz_pad;
+ border-top: $border_color 1px solid;
+ border-bottom: $border_color 1px solid;
+ text-align: center;
+ color: $med-grey;
+
+ p {
+ font-weight: bold;
+ font-size: $nav_font_size;
+ text-transform: uppercase;
+ }
+
+ p#header {
+ color: $black;
+ }
+
+ p:not(#header){
+ a {
+ color: $med-grey;
+ &:hover{
+ color: $accent;
+ }
+ }
+ }
+ }
+
+ article {
+ padding: 10px $horiz_pad 10px $horiz_pad;
+ max-width: $content_size;
+ margin: auto;
+
+ div.article_title {
+ }
+
+ div.article_text {
+ line-height: 1.375em;
+ a {
+ text-decoration: underline;
+ }
+
+ @mixin codeformat {
+ color: black;
+ font-size: $code_font_size;
+ font-family: $mono;
+ }
+
+ code {
+ @include codeformat;
+ padding: 1px 3px;
+ background: $light-grey;
+ }
+
+ pre {
+ @include codeformat;
+ line-height: 1.25;
+ margin-bottom: 0px;
+ margin-top: 0px;
+ padding-top: 5px;
+ padding-bottom: 10px;
+ }
+
+ div.highlight {
+ overflow: auto;
+ }
+
+ div.highlight pre {
+ display: inline-block;
+ padding-left: 10px;
+ overflow-x: auto;
+ border: 1px solid $code-border;
+ border-left: 3px solid $accent;
+ background: $code-background;
+ }
+
+ /* Line numbers are enabled */
+ table.highlighttable {
+ table-layout: fixed;
+ width: 100%;
+ border: 1px solid $code-border;
+ border-collapse: collapse;
+
+ pre {
+ border: none;
+ }
+
+ td {
+ padding: 0px;
+ }
+
+ td.linenos {
+ width: 30px;
+ overflow: hidden;
+ text-align: right;
+ border-left: 3px solid $accent;
+ border-right: 1px solid $code-border;
+ background: $code-linebg;
+ vertical-align:top;
+
+ div.linenodiv pre {
+ padding-right: 4px;
+ float: right;
+ }
+ }
+ }
+
+ div.admonition {
+
+ border-width: 1px 1px 1px 7px;
+ border-radius: 10px;
+ border-style: solid;
+ border-color: $code-border;
+ border-left-color: $dark-grey;
+ background-color: $code-background;
+
+ &.update, &.hint, &.important, &.note, &.tip {
+ border-left-color: $accent;
+ }
+
+ &.warning, &.attention, &.caution, &.danger {
+ border-left-color: $danger;
+ }
+
+ p {
+ margin: 0px;
+ padding: 10px 15px;
+ }
+
+ p.admonition-title{
+ text-transform: uppercase;
+ font-weight: bold;
+ padding: 5px 15px;
+ border-bottom: 1px solid $code-border;
+ }
+ }
+
+ blockquote {
+ margin: 22px 2px 22px 2px;
+ padding-left: $horiz_pad;
+ color: $med-grey;
+ font-style: italic;
+ }
+ }
+
+ div.article_meta {
+ font-size: $article_meta_font_size;
+ color: $med-grey;
+ }
+
+ div#article_comments{
+ padding-top: 10px;
+ padding-bottom: 10px;
+
+ i {
+ padding-right: 10px;
+ }
+ }
+ }
+
+ footer {
+ border-top: $border_color 1px solid;
+ padding: 30px $horiz_pad 30px $horiz_pad;
+
+ div#paginator {
+ font-weight: bold;
+ font-size: $pagination_font_size;
+ text-transform: uppercase;
+ overflow: auto;
+ text-align: center;
+
+ div{
+ display: inline-block;
+ }
+
+ span {
+ width: $pag_label_size;
+ }
+
+ span#left {
+ float: left;
+ text-align: left;
+ }
+
+ span#right {
+ float: right;
+ text-align: right;
+ }
+ }
+ }
+
+ div#ending_message {
+ border-top: $border_color 1px solid;
+ padding: 0 $horiz_pad 0 $horiz_pad;
+ color: $med-grey;
+ font-size: $footer_font_size;
+ }
+}
+
+@media screen and (min-width: $min_screen) {
+ aside {
+ width: 25%;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+
+ h2 {
+ margin: 10px 0 0 0;
+ }
+
+ div#user_meta {
+ max-width: 192px;
+ text-align: right;
+ margin: 0 20px 0 auto;
+ }
+ }
+
+ main {
+ position: absolute;
+ top: 0;
+ left: 25%;
+ width: 75%;
+ margin-left: -1px;
+ border-left: $border_color 1px solid;
+ overflow-x: hidden;
+ overflow-y: hidden;
+
+ header {
+ padding: 10px $horiz_pad 10px $horiz_pad;
+ border-top: 0;
+ text-align: left;
+ }
+
+ article {
+ width: $content_size;
+ margin: 0;
+ }
+
+ footer{
+ div#paginator {
+ width: $content_size;
+ }
+ }
+ }
+}
diff --git a/theme/static/js/email.js b/theme/static/js/email.js
new file mode 100644
index 0000000..90b736e
--- /dev/null
+++ b/theme/static/js/email.js
@@ -0,0 +1,21 @@
+// Unmangle email addresses on page load
+
+function unmangleEmail(){
+ var e = document.querySelectorAll(".email");
+ for (var i = 0; i < e.length; i++) {
+ var email = e[i].getAttribute("data-email")
+ var title = e[i].getAttribute("data-title")
+ if (email){
+ e[i].href = email.split("").reverse().join("");
+ e[i].removeAttribute("data-email");
+ e[i].removeAttribute("data-title");
+ e[i].removeAttribute("class");
+ if (title){
+ e[i].setAttribute("title", title);
+ }
+ else{
+ e[i].removeAttribute("title");
+ }
+ }
+ }
+}
diff --git a/theme/static/js/lw-timeago/README.md b/theme/static/js/lw-timeago/README.md
new file mode 100644
index 0000000..7d9a9eb
--- /dev/null
+++ b/theme/static/js/lw-timeago/README.md
@@ -0,0 +1,60 @@
+lw-timeago
+==========
+
+A lightweight implementation of the [Timeago jQuery plugin](http://timeago.yarp.com/), allowing fuzzy timestamps to be dynamically generated and displayed.
+
+Example
+-------
+
+#### Original
+```html
+<time datetime="2012-02-11T22:05:00-05:00">Feb 11, 2012</time>
+```
+
+#### Outputs
+```html
+<time title="Feb 11, 2012" datetime="2012-02-11T22:05:00-05:00">3 years ago</time>
+<time title="2/11/2012, 10:05:00 PM" datetime="2012-02-11T22:05:00-05:00">3 years ago on Feb 11, 2012</time>
+```
+
+Configuration
+-------------
+In the `config` section of the script there are a few options that can be configured.
+- `whitelist`: Only process `<time>` tags with this attribute. Setting it to `null` disables whitelisting (all `<time>` tags are processed).
+- `keepDate`: If `true`, don't replace the date in the `<time>` tags, prepend the fuzzy time to it (see the second example above).
+
+Additionally, the text can all be customized.
+
+Usage
+-----
+Step 2 can be skipped if `whitelist` is set to `null` in the config.
+
+1. Markup times in the HTML source with `<time>` tags, making sure they have a `datetime` attribute. The datetime attribute *MUST* be ISO 8601 formatted.
+2. For all `<time>` tags that should be converted to fuzzy times, add `data-timeago` attributes to them.
+3. Include the `lw-timeago.js` file in the html head (`<script src="lw-timeago.js" type="text/javascript"></script>`).
+4. Call the `lw_timeago()` function when the page loads (`<script type="text/javascript">window.addEventListener("load", lw_timeago);</script>`).
+
+License
+-------
+```
+Copyright (c) 2008-2015, Ryan McGeary
+Copyright (c) 2015 Carey Metcalfe
+
+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/theme/static/js/lw-timeago/lw-timeago.js b/theme/static/js/lw-timeago/lw-timeago.js
new file mode 100644
index 0000000..04a5c12
--- /dev/null
+++ b/theme/static/js/lw-timeago/lw-timeago.js
@@ -0,0 +1,91 @@
+var lw_timeago = function() {
+
+ var config = {
+ whitelist: "data-timeago", // Set to null to disable whitelisting
+
+ keepDate: true, // If true, appends the original date after the fuzzy one
+
+ suffixAgo: "ago",
+ suffixFromNow: "from now",
+ on: "on",
+
+ seconds: "less than a minute",
+ minute: "about a minute",
+ minutes: "%d minutes",
+ hour: "about an hour",
+ hours: "%d hours",
+ day: "about a day",
+ days: "%d days",
+ month: "about a month",
+ months: "%d months",
+ year: "about a year",
+ years: "%d years",
+ }
+
+ function inWords(distanceMillis) {
+ // Produce a string representing the milliseconds in a human-readable way
+
+ var suffix = distanceMillis < 0 ? config.suffixFromNow : config.suffixAgo;
+ var seconds = Math.abs(distanceMillis) / 1000;
+ var minutes = seconds / 60;
+ var hours = minutes / 60;
+ var days = hours / 24;
+ var years = days / 365;
+
+ function substitute(string, number) {
+ return string.replace(/%d/i, number);
+ }
+
+ var words =
+ seconds < 45 && substitute(config.seconds, Math.round(seconds)) ||
+ seconds < 90 && substitute(config.minute, 1) ||
+ minutes < 45 && substitute(config.minutes, Math.round(minutes)) ||
+ minutes < 90 && substitute(config.hour, 1) ||
+ hours < 24 && substitute(config.hours, Math.round(hours)) ||
+ hours < 42 && substitute(config.day, 1) ||
+ days < 30 && substitute(config.days, Math.round(days)) ||
+ days < 45 && substitute(config.month, 1) ||
+ days < 365 && substitute(config.months, Math.round(days / 30)) ||
+ years < 1.5 && substitute(config.year, 1) ||
+ substitute(config.years, Math.round(years));
+
+ return words + " " + suffix;
+ }
+
+ function diff(timestamp) {
+ // Get the number of milliseconds distance from the current time
+ return Date.now() - timestamp;
+ }
+
+ function doReplace(){
+ // Go over all <time> elements, grab the datetime attribute, then calculate
+ // and display a fuzzy representation of it.
+
+ var times = document.getElementsByTagName("time")
+ for (var i = 0; i < times.length; i++){
+
+ if (config.whitelist && !times[i].hasAttribute(config.whitelist))
+ break;
+
+ var datetime = times[i].getAttribute("datetime");
+ if (!datetime)
+ break;
+
+ var parsed = new Date(datetime);
+ if (!parsed)
+ break;
+
+ var words = inWords(diff(parsed.getTime()));
+ var title = times[i].innerHTML;
+ if (config.keepDate){
+ words += " " + config.on + " " + times[i].innerHTML;
+ title = parsed.toLocaleString()
+ }
+
+ times[i].title = title;
+ times[i].innerHTML = words;
+ }
+ }
+
+ return doReplace;
+}();
diff --git a/theme/static/js/scroll.js b/theme/static/js/scroll.js
new file mode 100644
index 0000000..6ec5f38
--- /dev/null
+++ b/theme/static/js/scroll.js
@@ -0,0 +1,8 @@
+// Scroll past header on smaller screens
+
+function skipHeader(){
+ if (window.location.pathname != '/' && window.location.pathname != '/index.html'){
+ window.scroll(0, document.getElementById('main').offsetTop);
+ }
+}
+
diff --git a/theme/templates/archives.html b/theme/templates/archives.html
new file mode 100644
index 0000000..2985f85
--- /dev/null
+++ b/theme/templates/archives.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+{% block title %}Archive | {{ SITENAME }}{% endblock %}
+
+{% block description %}A listing of all posts on this site{% endblock %}
+
+{% block content %}
+<article>
+ <div class="article_text">
+ <dl>
+ {% for year, year_articles in articles|groupby('date.year')|reverse %}
+ <h2>{{ year }}</h2>
+ {% for _, month_articles in year_articles|groupby('date.month')|reverse %}
+ {# A little gross, but works (need a date object to get the month name). #}
+ <h3>{{ month_articles[0].date.strftime('%B') }}</h3>
+ <ul>
+ {% for article in month_articles %}
+ <li><time datetime="{{ article.date.isoformat() }}">{{ article.date.strftime('%b %d, %Y') }}</time> - <a href="{{ SITEURL }}/{{ article.url }}">{{ article.title }}</a></li>
+ {% endfor %}
+ </ul>
+ {% endfor %}
+ {% endfor %}
+ </dl>
+ </div>
+</article>
+{% endblock %}
diff --git a/theme/templates/article.html b/theme/templates/article.html
new file mode 100644
index 0000000..11e32c5
--- /dev/null
+++ b/theme/templates/article.html
@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+{% block head %}
+<meta name="keywords" content="
+ {%- if GLOBAL_KEYWORDS -%}
+ {{ GLOBAL_KEYWORDS|join(', ') }}
+ {%- if article.tags -%}{{', '}}{%- endif -%}
+ {%- endif -%}
+ {{article.tags|join(', ') }}">
+{% endblock %}
+
+{% block title %}{{ article.title|striptags|escape }} | {{ SITENAME }}{% endblock %}
+{% block description %}{{ article.title|striptags|escape }} | {{ article.content|striptags|truncate(154-article.title|length)|escape }}{% endblock %}
+
+{% block content %}
+<article>
+ {% if DISPLAY_META_ABOVE_ARTICLE %}
+ {% include "modules/article_meta.html" %}
+ {% endif %}
+ <div class="article_title">
+ <h1><a href="{{ SITEURL }}/{{ article.url }}" class="nohover">{{ article.title }}</a></h1>
+ {% if DISPLAY_SUBTITLE %}
+ <h4>{{ article.subtitle }}</a></h4>
+ {% endif %}
+ </div>
+ <div class="article_text">
+ {{ article.content }}
+ </div>
+ {% if article.photo_gallery %}
+ <div class="gallery">
+ {% for title, gallery in article.photo_gallery %}
+ <h1>{{ title }}</h1>
+ {% for name, photo, thumb, exif, caption in gallery %}
+ <a href="{{ SITEURL }}/{{ photo }}" title="{{ name }}" exif="{{ exif }}" caption="{{ caption }}"><img src="{{ SITEURL }}/{{ thumb }}"></a>
+ {% endfor %}
+ {% endfor %}
+ </div>
+ {% endif %}
+ {% if not DISPLAY_META_ABOVE_ARTICLE %}
+ {% include "modules/article_meta.html" %}
+ {% endif %}
+
+ {% if DISQUS_SITENAME %}
+ <div id="article_comments">
+ <div id="disqus_thread">
+ {% if DISQUS_COLLAPSED %}
+ <a href="javascript:loadDisqus()"><i class="fa fa-comments fa-lg"></i>Click to load <span class="disqus-comment-count" data-disqus-identifier="{{ article.url }}">comments</span></a>
+ {% endif %}
+ </div>
+ </div>
+
+ <script id="dsq-count-scr" src="https://{{ DISQUS_SITENAME }}.disqus.com/count.js" async></script>
+ <script type="text/javascript">
+ var disqus_config = function () {
+ this.page.identifier = "{{ article.url }}";
+ this.page.title = "{{ article.title }}";
+ };
+ loadDisqus = function(sitename) {
+ var d = document, s = d.createElement('script');
+ s.src = 'https://{{ DISQUS_SITENAME }}.disqus.com/embed.js';
+ s.setAttribute('data-timestamp', +new Date());
+ (d.head || d.body).appendChild(s);
+ };
+ {% if not DISQUS_COLLAPSED %}
+ loadDisqus();
+ {% endif %}
+ </script>
+ {% endif %}
+</article>
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{% if FUZZY_DATES %}
+<script type="text/javascript">window.addEventListener("load", lw_timeago);</script>
+{% endif %}
+{% endblock %}
diff --git a/theme/templates/author.html b/theme/templates/author.html
new file mode 100644
index 0000000..ee17552
--- /dev/null
+++ b/theme/templates/author.html
@@ -0,0 +1,8 @@
+{% extends "index.html" %}
+{% block title %}Author: {{ author }} | {{ SITENAME }}{% endblock %}
+
+{% block description %}Posts by {{ author }} on this site{% endblock %}
+
+{% block subheader %}
+<p>Author: {{ author }}</p>
+{% endblock %}
diff --git a/theme/templates/authors.html b/theme/templates/authors.html
new file mode 100644
index 0000000..6d4478e
--- /dev/null
+++ b/theme/templates/authors.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Authors | {{ SITENAME }}{% endblock %}
+
+{% block description %}A listing of all authors on this site{% endblock %}
+
+{% block content %}
+<article>
+ <div class="article_text">
+ <ul>
+ {% for author, articles in authors|sort %}
+ <li><a href="{{ SITEURL }}/{{ author.url }}">{{ author }}</a> ({{ articles|count }})</li>
+ {% endfor %}
+ </ul>
+ </div>
+</article>
+{% endblock %}
+
diff --git a/theme/templates/base.html b/theme/templates/base.html
new file mode 100644
index 0000000..b59631a
--- /dev/null
+++ b/theme/templates/base.html
@@ -0,0 +1,185 @@
+<!DOCTYPE html>
+<html lang="{{ DEFAULT_LANG }}">
+
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="author" content="{{ AUTHOR }}">
+ <meta name="description" content="{% block description %}Posts by {{ AUTHOR }}{% endblock %}">
+
+ {% block base %}{% endblock %}
+ {% assets filters="pyscss,cssmin", output="css/styles.%(version)s.min.css", "css/style.scss", "css/pygments.css" %}
+ <link rel="stylesheet" type="text/css" href="{{ THEMEURL }}/{{ ASSET_URL }}">
+ {% endassets %}
+
+ {% assets filters="slimit", output="js/scripts.%(version)s.min.js", "js/lw-timeago/lw-timeago.js", "js/scroll.js", "js/email.js" %}
+ <script src="{{ THEMEURL }}/{{ ASSET_URL }}" type="text/javascript"></script>
+ {% endassets%}
+
+ <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" rel="stylesheet">
+
+ {% if FEED_ALL_ATOM %}
+ <link href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}" type="application/atom+xml" rel="alternate" title="{{ SITENAME }} Full Atom Feed" />
+ {% endif %}
+ {% if FEED_ALL_RSS %}
+ <link href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}" type="application/rss+xml" rel="alternate" title="{{ SITENAME }} Full RSS Feed" />
+ {% endif %}
+
+ {% block head %}
+ <meta name="keywords" content="
+ {%- if GLOBAL_KEYWORDS -%}
+ {{ GLOBAL_KEYWORDS|join(', ') }}
+ {%- if tags -%}{{', '}}{%- endif -%}
+ {%- endif -%}
+ {{tags|join(', ', attribute='0') }}">
+ {% endblock %}
+
+ <title>{% block title %}{{ SITENAME }}{% endblock %}</title>
+
+ {% include "modules/analytics.html" %}
+</head>
+
+{% macro get_icon(link) %}
+ {%- set class = 'fa-external-link-square' -%}
+
+ {# Attempt to normalize link URLs #}
+ {%- set temp = link|lower|replace('http://', '')|replace('https://', '')|replace('www.', '') -%}
+
+ {# Choosing a specific link icon #}
+ {%- if temp.startswith('bitcoin:') %}{% set class = 'fa-btc' %}
+ {% elif temp.startswith('irc:') %}{% set class = 'fa-comments' %}
+ {% elif temp.startswith('mailto:') %}{% set class = 'fa-envelope' %}
+ {% elif temp.startswith('skype:') %}{% set class = 'fa-skype' -%}
+
+ {%- elif temp.endswith('.pdf') or
+ temp.endswith('.txt') or
+ temp.endswith('.doc') or
+ temp.endswith('.docx') %}{% set class = 'fa-file-text' -%}
+
+ {%- elif temp.startswith('500px.com') %}{% set class = 'fa-500px' %}
+ {% elif temp.startswith('behance.net') %}{% set class = 'fa-behance' %}
+ {% elif temp.startswith('bitbucket.org') %}{% set class = 'fa-bitbucket' %}
+ {% elif temp.startswith('codepen.io') %}{% set class = 'fa-codepen' %}
+ {% elif temp.startswith('delicious.com') %}{% set class = 'fa-delicious' %}
+ {% elif temp.startswith('deviantart.com') %}{% set class = 'fa-deviantart' %}
+ {% elif temp.startswith('facebook.com') %}{% set class = 'fa-facebook' %}
+ {% elif temp.startswith('flickr.com') %}{% set class = 'fa-flickr' %}
+ {% elif temp.startswith('foursquare.com') %}{% set class = 'fa-foursquare' %}
+ {% elif temp.startswith('github.com') %}{% set class = 'fa-github' %}
+ {% elif temp.startswith('gitlab.com') %}{% set class = 'fa-gitlab' %}
+ {% elif temp.startswith('instagram.com') %}{% set class = 'fa-instagram' %}
+ {% elif temp.startswith('last.fm') %}{% set class = 'fa-lastfm' %}
+ {% elif temp.startswith('linkedin.com') %}{% set class = 'fa-linkedin' %}
+ {% elif temp.startswith('news.ycombinator.com') %}{% set class = 'fa-hacker-news' %}
+ {% elif temp.startswith('pinterest.com') %}{% set class = 'fa-pinterest' %}
+ {% elif temp.startswith('plus.google.com') %}{% set class = 'fa-google-plus-official' %}
+ {% elif temp.startswith('reddit.com') %}{% set class = 'fa-reddit' %}
+ {% elif temp.startswith('snapchat.com') %}{% set class = 'fa-snapchat-ghost' %}
+ {% elif temp.startswith('spotify.com') %}{% set class = 'fa-spotify' %}
+ {% elif temp.startswith('stackoverflow.com') %}{% set class = 'fa-stack-overflow' %}
+ {% elif temp.startswith('steamcommunity.com') %}{% set class = 'fa-steam' %}
+ {% elif temp.startswith('soundcloud.com') %}{% set class = 'fa-soundcloud' %}
+ {% elif temp.startswith('twitch.tv') %}{% set class = 'fa-twitch' %}
+ {% elif temp.startswith('twitter.com') %}{% set class = 'fa-twitter' %}
+ {% elif temp.startswith('vimeo.com') %}{% set class = 'fa-vimeo-square' %}
+ {% elif temp.startswith('vine.co') %}{% set class = 'fa-vine' %}
+ {% elif temp.startswith('youtube.com') %}{% set class = 'fa-youtube' -%}
+ {% elif temp.startswith('keybase.io') %}{% set class = 'fa-key' -%}
+
+ {%- elif '.stackexchange.com' in temp %}{% set class = 'fa-stack-exchange' %}
+ {% elif '.tumblr.com' in temp %}{% set class = 'fa-tumblr' %}
+ {% elif 'debian.org' in temp %}{% set class = 'fa fa-linux fa-lg' %}
+ {% endif -%}
+
+ <i class="fa {{ class }} fa-lg"></i>
+{%- endmacro -%}
+
+{%- macro display_link(name, link, text) -%}
+ {%- if MANGLE_EMAILS and link.startswith('mailto:') -%}
+ <a data-email="{{ link|reverse }}"{% if not text %} data-title="{{ name }}"{% endif %} title="You need javascript enabled to view this email" class="email" target="_blank" rel="noopener noreferrer">{{ text }}{{ get_icon(link) }}</a>
+ {%- else -%}
+ <a href="{{ link }}"{% if not text %} title="{{ name }}"{% endif %} target="_blank" rel="noopener noreferrer">{{ text }}{{ get_icon(link) }}</a>
+ {%- endif -%}
+{%- endmacro -%}
+
+<body>
+ <aside>
+ <div id="user_meta">
+ <a href="{{ SITEURL }}">
+ {% if USER_LOGO_URL %}
+ <img src="{{ USER_LOGO_URL }}" id="logo">
+ {% endif %}
+ </a>
+ <h2><a href="{{ SITEURL }}" class="nohover">{{ SITENAME }}</a></h2>
+ {{ TAGLINE }}
+ {% if SOCIAL|length > 1 %}
+ <div class="social">
+ {% for name, link in SOCIAL %}
+ {{ display_link(name, link, "") }}
+ {% endfor %}
+ </div>
+ {% endif %}
+ {% if LINKS|length > 1 %}
+ <ul>
+ {% for name, link in LINKS %}
+ <li>{{ display_link(name, link, name) }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </div>
+ </aside>
+
+ <main id="main">
+ <header>
+ {% block header %}
+ <p id="header">
+ <a href="{{ SITEURL }}">Home</a>
+
+ {% if DISPLAY_PAGES_ON_MENU %}
+ {% for p in pages %}
+ {% if p.url != "index.html" %}
+ | <a href="{{ SITEURL }}/{{ p.url }}">{{ p.title }}</a>
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+ {% if INDEX_SAVE_AS and INDEX_SAVE_AS != "index.html" %}
+ | <a href="{{ SITEURL }}/{{ INDEX_LINK_AS }}">Blog</a>
+ {% endif %}
+ {% if FEED_ALL_ATOM %}
+ | <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}">Atom Feed</a>
+ {% endif %}
+ {% if FEED_ALL_RSS %}
+ | <a href="{{ FEED_DOMAIN }}/{{ FEED_ALL_RSS }}">RSS Feed</a>
+ {% endif %}
+ {% for title, link in MENUITEMS %}
+ | <a href="{{ SITEURL }}/{{ link }}">{{ title }}</a>
+ {% endfor %}
+ </p>
+ {% endblock header %}
+ {% block subheader %}{% include "modules/blogsubheader.html" %}{% endblock subheader %}
+ </header>
+
+ {% block content %}
+ {% endblock %}
+
+ {% block footer %}
+ {% endblock %}
+
+ <div id="ending_message">
+ <p>{{ SITENAME }}. Built using <a href="http://getpelican.com">Pelican</a>. Theme is <a href="https://github.com/pR0Ps/pelican-subtle">subtle</a> by <a href="http://cmetcalfe.ca">Carey Metcalfe</a>. Based on <a href="https://github.com/giulivo/pelican-svbhack">svbhack</a> by Giulio Fidente.</p>
+ </div>
+ </main>
+
+ {% block scripts %}
+ <script type="text/javascript">
+ window.addEventListener('load', skipHeader)
+ </script>
+
+ {% if MANGLE_EMAILS %}
+ <script type="text/javascript">
+ window.addEventListener('load', unmangleEmail)
+ </script>
+ {% endif %}
+ {% endblock scripts %}
+</body>
+</html>
diff --git a/theme/templates/categories.html b/theme/templates/categories.html
new file mode 100644
index 0000000..a639502
--- /dev/null
+++ b/theme/templates/categories.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+{% block title %}Categories | {{ SITENAME }}{% endblock %}
+
+{% block description %}A listing of all categories on this site{% endblock %}
+
+{% block content %}
+<article>
+ <div class="article_text">
+ <ul>
+ {% for category, articles in categories|sort %}
+ <li><a href="{{ SITEURL }}/{{ category.url }}">{{ category }}</a> ({{ articles|count }})</li>
+ {% endfor %}
+ </ul>
+ </div>
+</article>
+{% endblock %}
diff --git a/theme/templates/category.html b/theme/templates/category.html
new file mode 100644
index 0000000..cfd4d2d
--- /dev/null
+++ b/theme/templates/category.html
@@ -0,0 +1,23 @@
+{% extends "index.html" %}
+{% block title %}Category: {{ category }} | {{ SITENAME }}{% endblock %}
+
+{% block description %}Posts in the {{ category }} section of this site{% endblock %}
+
+{% block head %}
+{% if CATEGORY_FEED_ATOM %}
+<link href="{{ FEED_DOMAIN }}/{{ CATEGORY_FEED_ATOM|replace("{slug}", category.slug) }}" type="application/atom+xml" rel="alternate" title="{{ SITENAME }} {{ category }} Category Atom Feed" />
+{% endif %}
+{% if CATEGORY_FEED_RSS %}
+<link href="{{ FEED_DOMAIN }}/{{ CATEGORY_FEED_RSS|replace("{slug}", category.slug) }}" type="application/rss+xml" rel="alternate" title="{{ SITENAME }} {{ category }} Category RSS Feed" />
+{% endif %}
+{% endblock %}
+
+{% block subheader %}
+<p>Category: {{ category }}
+{% if CATEGORY_FEED_ATOM %}
+| <a href="{{ FEED_DOMAIN }}/{{ CATEGORY_FEED_ATOM|replace("{slug}", category.slug) }}">Atom Feed</a>
+{% endif %}
+{% if CATEGORY_FEED_RSS %}
+| <a href="{{ FEED_DOMAIN }}/{{ CATEGORY_FEED_RSS|replace("{slug}", category.slug) }}">RSS Feed</a>
+{% endif %}</p>
+{% endblock %}
diff --git a/theme/templates/error.html b/theme/templates/error.html
new file mode 100644
index 0000000..98c1ada
--- /dev/null
+++ b/theme/templates/error.html
@@ -0,0 +1,5 @@
+{% extends "page.html" %}
+
+{% block base %}
+ <base href="/">
+{% endblock %}
diff --git a/theme/templates/index.html b/theme/templates/index.html
new file mode 100644
index 0000000..099ed0f
--- /dev/null
+++ b/theme/templates/index.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block title %}Blog | {{ SITENAME }}{% endblock %}
+
+{% block content %}
+{% for article in articles_page.object_list|sort(attribute='no') %}
+<article>
+ <div class="article_title">
+ <h3><a href="{{ BLOGURL }}/{{ article.url }}">{{ article.title }}</a></h3>
+ {{ article.summary }}
+ </div>
+</article>
+{% if not loop.last %}
+<hr />
+{% endif %}
+{% endfor %}
+{% endblock %}
+
+{% if BLOCK_FOOTER %}
+{% block footer %}
+<footer>
+{% include "modules/pagination.html" %}
+</footer>
+{% endblock %}
+{% endif %}
diff --git a/theme/templates/invite.html b/theme/templates/invite.html
new file mode 100644
index 0000000..e73aede
--- /dev/null
+++ b/theme/templates/invite.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %}
+{% block title %}{{ page.title|striptags|escape }} | {{ SITENAME }}{% endblock %}
+
+{% block subheader %}{% endblock %}
+
+{% block content %}
+
+<article>
+ <div class="article_title">
+ <h1><a href="{{ SITEURL }}/{{ page.url }}" class="nohover">{{ page.title }}</a></h1>
+ </div>
+ <div class="article_text">
+ {{ page.content }}
+ </div>
+ <div class="article_text">
+ <?php echo form_open('invite/view', 'id = inviteform'); ?>
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('username'); ?></div>
+ <input class="input" type="text" name="username" placeholder="Username" id="username">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('password'); ?></div>
+ <input class="input" type="password" name="password" placeholder="Password" id="password">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('passconf'); ?></div>
+ <input class="input" type="password" name="passconf" placeholder="Repeat password" id="passconf">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('email'); ?></div>
+ <input class="input" type="email" name="email" placeholder="example@domain.com" id="email">
+ </fieldset>
+
+ <fieldset class="field">
+ <input class="button submit" type="submit" value="Invite me">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('submit_msg'); ?></div>
+ <div class="submit_msg"><?php echo form_message(); ?></div>
+ <input type="hidden" name="submit_msg" id="submit_msg">
+ </fieldset>
+ </form>
+ </div>
+</article>
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{% endblock scripts %}
diff --git a/theme/templates/login.html b/theme/templates/login.html
new file mode 100644
index 0000000..1d5dba0
--- /dev/null
+++ b/theme/templates/login.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% block title %}{{ page.title|striptags|escape }} | {{ SITENAME }}{% endblock %}
+
+{% block subheader %}{% endblock %}
+
+{% block content %}
+
+<article>
+ <div class="article_title">
+ <h1><a href="{{ SITEURL }}/{{ page.url }}" class="nohover">{{ page.title }}</a></h1>
+ </div>
+ <div class="article_text">
+ {{ page.content }}
+ </div>
+ <div class="article_text">
+ <?php echo form_open('login/view', 'id = loginform'); ?>
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('username'); ?></div>
+ <input class="input" type="text" name="username" placeholder="Username" id="username">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('password'); ?></div>
+ <input class="input" type="password" name="password" placeholder="Password" id="password">
+ </fieldset>
+
+ <fieldset class="field">
+ <input class="button submit" type="submit" value="Login">
+ </fieldset>
+
+ <fieldset class="field">
+ <div class="form_errors"><?php echo form_error('submit_msg'); ?></div>
+ <div class="submit_msg"><?php echo form_message(); ?></div>
+ <input type="hidden" name="submit_msg" id="submit_msg">
+ </fieldset>
+ </form>
+ </div>
+</article>
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{% endblock scripts %}
diff --git a/theme/templates/modules/analytics.html b/theme/templates/modules/analytics.html
new file mode 100644
index 0000000..503e0ec
--- /dev/null
+++ b/theme/templates/modules/analytics.html
@@ -0,0 +1,11 @@
+{% if GOOGLE_ANALYTICS_ID and GOOGLE_ANALYTICS_PROP %}
+<script>
+ (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
+ (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
+ m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
+ })(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
+
+ ga('create', '{{ GOOGLE_ANALYTICS_ID }}', '{{ GOOGLE_ANALYTICS_PROP }}');
+ ga('send', 'pageview');
+</script>
+{% endif %}
diff --git a/theme/templates/modules/article_meta.html b/theme/templates/modules/article_meta.html
new file mode 100644
index 0000000..7aab905
--- /dev/null
+++ b/theme/templates/modules/article_meta.html
@@ -0,0 +1,22 @@
+<div class="article_meta">
+ <p>Posted <time data-timeago datetime="{{ article.date.isoformat() }}">{{ article.locale_date }}</time>
+ {% if article.author %}
+ by {% if AUTHORS_URL %} <a href="{{ SITEURL }}/{{ article.author.url }}">{{ article.author }}</a>{% else %}{{ article.author}}{% endif %}
+ {% endif %}
+ </p>
+ {% if article.modified %}
+ <p>Last updated <time data-timeago datetime="{{ article.modified.isoformat() }}">{{ article.locale_modified }}</time></p>
+ {% endif %}
+ <p>
+ {% if CATEGORY_URL %}
+ Category: <a href="{{ SITEURL }}/{{ article.category.url }}">{{ article.category }}</a>
+ {% if TAG_URL and article.tags %}––{% endif %}
+ {% endif %}
+ {% if TAG_URL and article.tags %}
+ Tags:
+ {% for tag in article.tags %}
+ <a href="{{ SITEURL }}/{{ tag.url }}">{{ tag }}</a>{% if not loop.last %},{% endif %}
+ {% endfor %}
+ </p>
+ {% endif %}
+</div>
diff --git a/theme/templates/modules/blogsubheader.html b/theme/templates/modules/blogsubheader.html
new file mode 100644
index 0000000..9ba047d
--- /dev/null
+++ b/theme/templates/modules/blogsubheader.html
@@ -0,0 +1,19 @@
+<p>
+<!--{% if INDEX_SAVE_AS %}
+<a href="{{ SITEURL }}/{{ INDEX_SAVE_AS }}">Blog</a>
+{% else %}
+<a href="{{ SITEURL }}">Blog</a>
+{% endif %} NOTE: This will be redundant now. -->
+{% if TAGS_URL %}
+| <a href="{{ SITEURL }}/{{ TAGS_URL }}">Tags</a>
+{% endif %}
+{% if CATEGORIES_URL %}
+| <a href="{{ SITEURL }}/{{ CATEGORIES_URL }}">Categories</a>
+{% endif %}
+{% if AUTHORS_URL %}
+| <a href="{{ SITEURL }}/{{ AUTHORS_URL }}">Authors</a>
+{% endif %}
+{% if ARCHIVES_URL %}
+| <a href="{{ SITEURL }}/{{ ARCHIVES_URL }}">Archive</a>
+{% endif %}
+</p>
diff --git a/theme/templates/modules/pagination.html b/theme/templates/modules/pagination.html
new file mode 100644
index 0000000..061dbe0
--- /dev/null
+++ b/theme/templates/modules/pagination.html
@@ -0,0 +1,20 @@
+{% if DEFAULT_PAGINATION %}
+<div id="paginator">
+ <span id="left">
+ {% if articles_page.has_previous() %}
+ {% set num = articles_page.previous_page_number() %}
+ <a href="{{ SITEURL }}/{{ page_name }}{{ num if num > 1 else '' }}.html"><i class="fa fa-arrow-circle-left"></i> Prev</a>
+ {% endif %}
+ </span>
+ <span id="right">
+ {% if articles_page.has_next() %}
+ <a href="{{ SITEURL }}/{{ page_name }}{{ articles_page.next_page_number() }}.html">Next <i class="fa fa-arrow-circle-right"></i></a>
+ {% endif %}
+ </span>
+ <div>
+ {% for num in range( 1, 1 + articles_paginator.num_pages ) %}
+ <a href="{{ SITEURL }}/{{ page_name }}{{ num if num > 1 else '' }}.html">{{'[' + num|string + ']' if num == articles_page.number else ' ' + num|string + ' ' }}</a>
+ {% endfor %}
+ </div>
+</div>
+{% endif %}
diff --git a/theme/templates/page.html b/theme/templates/page.html
new file mode 100644
index 0000000..e2608c2
--- /dev/null
+++ b/theme/templates/page.html
@@ -0,0 +1,19 @@
+{% extends "base.html" %}
+{% block title %}{{ page.title|striptags|escape }} | {{ SITENAME }}{% endblock %}
+
+{% block subheader %}{% endblock %}
+
+{% block content %}
+<article>
+ <div class="article_title">
+ <h1><a href="{{ SITEURL }}/{{ page.url }}" class="nohover">{{ page.title }}</a></h1>
+ </div>
+ <div class="article_text">
+ {{ page.content }}
+ </div>
+</article>
+{% endblock %}
+
+{% block scripts %}
+{{ super() }}
+{% endblock scripts %}
diff --git a/theme/templates/tag.html b/theme/templates/tag.html
new file mode 100644
index 0000000..de23d2b
--- /dev/null
+++ b/theme/templates/tag.html
@@ -0,0 +1,23 @@
+{% extends "index.html" %}
+{% block title %}Tag: {{ tag }} | {{ SITENAME }}{% endblock %}
+
+{% block description %}Posts tagged with '{{ tag }}' on this site{% endblock %}
+
+{% block head %}
+{% if TAG_FEED_ATOM %}
+<link href="{{ FEED_DOMAIN }}/{{ TAG_FEED_ATOM|replace("{slug}", tag.slug) }}" type="application/atom+xml" rel="alternate" title="{{ SITENAME }} {{ tag }} Tag Atom Feed" />
+{% endif %}
+{% if TAG_FEED_RSS %}
+<link href="{{ FEED_DOMAIN }}/{{ TAG_FEED_RSS|replace("{slug}", tag.slug) }}" type="application/rss+xml" rel="alternate" title="{{ SITENAME }} {{ tag }} Tag RSS Feed" />
+{% endif %}
+{% endblock %}
+
+{% block subheader %}
+<p>Tag: {{ tag }}
+{% if TAG_FEED_ATOM %}
+| <a href="{{ FEED_DOMAIN }}/{{ TAG_FEED_ATOM|replace("{slug}", tag.slug) }}">Atom Feed</a>
+{% endif %}
+{% if TAG_FEED_RSS %}
+| <a href="{{ FEED_DOMAIN }}/{{ TAG_FEED_RSS|replace("{slug}", tag.slug) }}">RSS Feed</a>
+{% endif %}</p>
+{% endblock %}
diff --git a/theme/templates/tags.html b/theme/templates/tags.html
new file mode 100644
index 0000000..7f567c2
--- /dev/null
+++ b/theme/templates/tags.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %}
+{% block title %}Tags | {{ SITENAME }}{% endblock %}
+
+{% block description %}A listing of all tags on this site{% endblock %}
+
+{% block content %}
+<article>
+ <div class="article_text">
+ <ul>
+ {% for tag, articles in tags|sort %}
+ <li><a href="{{ SITEURL }}/{{ tag.url }}">{{ tag }}</a> ({{ articles|count }})</li>
+ {% endfor %}
+ </ul>
+ </div>
+</article>
+{% endblock %}