├── setup.py ├── .github └── workflows │ └── pylint.yml ├── _hooks ├── status.py ├── markdown2.py ├── template_filters.py ├── deploy_rsync.py ├── posts.py └── posts_ui.py ├── README.markdown └── growl.py /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="growl", 5 | version="0.3", 6 | scripts=["growl.py"] 7 | ) -------------------------------------------------------------------------------- /.github/workflows/pylint.yml: -------------------------------------------------------------------------------- 1 | name: Pylint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install pylint 21 | - name: Analysing the code with pylint 22 | run: | 23 | pylint $(git ls-files '*.py') 24 | -------------------------------------------------------------------------------- /_hooks/status.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | import sys 21 | 22 | 23 | @wrap(Post.write) 24 | def verbose_post_write(forig, self): 25 | sys.stderr.write('post: %s - %s\n' % 26 | (self.date.strftime('%Y-%m-%d'), self.title)) 27 | return forig(self) 28 | 29 | 30 | @wrap(Page.write) 31 | def verbose_page_write(forig, self): 32 | sys.stderr.write('page: %s\n' % self.path) 33 | return forig(self) 34 | -------------------------------------------------------------------------------- /_hooks/markdown2.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | """ 21 | copy 'markdown2.py' from http://code.google.com/p/python-markdown2/ 22 | to _libs directory. 23 | """ 24 | 25 | import markdown2 26 | import functools 27 | 28 | Config.transformers['markdown2'] = functools.partial( 29 | markdown2.markdown, 30 | extras={'code-color': {"classes": True}}) 31 | 32 | Config.transformers['md2'] = Config.transformers['markdown2'] 33 | -------------------------------------------------------------------------------- /_hooks/template_filters.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | 21 | @templateFilter 22 | def dateFormat(dt, format='%Y-%m-%d'): 23 | return dt.strftime(format) 24 | 25 | 26 | @templateFilter 27 | def xmldatetime(dt): 28 | """ shameless stolen from http://github.com/lakshmivyas/hyde 29 | thanks alot 30 | """ 31 | zprefix = "Z" 32 | tz = dt.strftime("%z") 33 | if tz: 34 | zprefix = tz[:3] + ":" + tz[3:] 35 | return dt.strftime("%Y-%m-%dT%H:%M:%S") + zprefix 36 | 37 | 38 | @templateFilter 39 | def xtruncate(s, length=255, end='...'): 40 | import tidy 41 | 42 | options = dict(output_xhtml=1, 43 | add_xml_decl=1, 44 | indent=1, 45 | show_body_only=1, 46 | tidy_mark=0) 47 | return str(tidy.parseString(str(s[:length]) + end, **options)) 48 | -------------------------------------------------------------------------------- /_hooks/deploy_rsync.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | import subprocess 21 | 22 | REMOTE_PATH = 'user@host:/path/' 23 | 24 | @wrap(Site.setupOptions) 25 | def setupOptions(forig, self, parser): 26 | forig(self, parser) 27 | parser.set_defaults(deploy = False) 28 | parser.add_option('--deploy', 29 | action = 'store_true', dest = 'deploy', 30 | help = 'deploy site') 31 | 32 | 33 | @wrap(Site.run) 34 | def run_rsync(forig, self): 35 | # first run 'default' actions and maybe other run hooks 36 | forig(self) 37 | 38 | if self.options.deploy: 39 | 40 | cmd = 'rsync -ahz --delete %s/* %s\n' % (self.DEPLOY_DIR, REMOTE_PATH) 41 | sys.stderr.write('deploy to >>> %s\n' % REMOTE_PATH) 42 | ret = subprocess.call(cmd, shell=True) 43 | if ret == 0: 44 | sys.stderr.write('<<< finished\n') 45 | else: 46 | sys.stderr.write('<<< failed! (return code: %d)\n' % ret) 47 | -------------------------------------------------------------------------------- /_hooks/posts.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | import os 21 | import datetime 22 | 23 | 24 | class Post(Page): 25 | """ a post template mapping a single post from the _posts/ 26 | directory. 27 | """ 28 | 29 | def __init__(self, filename, layout, context): 30 | super(Post, self).__init__(filename, layout, context) 31 | 32 | base = os.path.basename(filename) 33 | ext = os.path.splitext(base) 34 | 35 | self.year, self.month, self.day, self.slug = ext[0].split('-', 3) 36 | 37 | self.context.post = self 38 | 39 | cats = ','.join((self.context.get('category', ''), 40 | self.context.get('categories', ''))) 41 | if 'category' in self.context: 42 | del self.context['category'] 43 | if 'categories' in self.context: 44 | del self.context['categories'] 45 | self.categories = [c.strip() for c in cats.split(',') if c] 46 | 47 | @property 48 | def date(self): 49 | return datetime.datetime(int(self.year), 50 | int(self.month), 51 | int(self.day)) 52 | 53 | @property 54 | def url(self): 55 | return os.path.join(self.year, self.month, self.day, self.slug) 56 | 57 | @property 58 | def path(self): 59 | return os.path.join(self.url, 'index' + self.POST_FILE_EXT) 60 | 61 | @property 62 | def content(self): 63 | return self.render() 64 | 65 | @property 66 | def publish(self): 67 | return self.context.get('publish', True) 68 | 69 | def __cmp__(self, other): 70 | return cmp(self.date, other.date) 71 | 72 | @staticmethod 73 | def setup(clazz): 74 | clazz.POST_DIR = os.path.join(clazz.BASE_DIR, '_posts') 75 | 76 | def read_posts(self): 77 | self.posts = [] 78 | if os.path.isdir(self.POST_DIR): 79 | self.posts = [Post(os.path.join(self.POST_DIR, f), 80 | self.layouts, 81 | self.context) 82 | for f in self.ignoreFilter(os.listdir( 83 | self.POST_DIR))] 84 | self.context.site.posts = sorted(p for p in self.posts 85 | if p.publish) 86 | self.context.site.unpublished_posts = sorted(p for 87 | p in self.posts 88 | if not p.publish) 89 | 90 | def calc_categories(self): 91 | self.categories = AttrDict() 92 | for post in self.posts: 93 | if post.publish: 94 | for cat in post.categories: 95 | self.categories.setdefault(cat, []).append(post) 96 | if not post.categories: 97 | self.categories.setdefault(None, []).append(post) 98 | self.context.site.categories = self.categories 99 | 100 | def write_posts(self): 101 | for p in self.posts: 102 | p.write() 103 | 104 | @wrap(clazz.prepare) 105 | def site_prepare(forig, self): 106 | """ read all posts and calculate the categories. 107 | """ 108 | forig(self) 109 | read_posts(self) 110 | calc_categories(self) 111 | 112 | @wrap(clazz.run) 113 | def site_run(forig, self): 114 | """ write all posts to the deploy directory. 115 | """ 116 | write_posts(self) 117 | forig(self) 118 | 119 | Post.setup(Site) # whooha! 120 | -------------------------------------------------------------------------------- /_hooks/posts_ui.py: -------------------------------------------------------------------------------- 1 | # vim:syntax=python:sw=4:ts=4:expandtab 2 | # 3 | # Copyright (C) 2009 Rico Schiekel (fire at downgra dot de) 4 | # 5 | # This program is free software; you can redistribute it and/or 6 | # modify it under the terms of the GNU General Public License version 2 7 | # as published by the Free Software Foundation 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 17 | # MA 02110-1301, USA. 18 | # 19 | 20 | import os 21 | import time 22 | import datetime 23 | import tempfile 24 | import textwrap 25 | import readline 26 | import urllib 27 | import hashlib 28 | 29 | 30 | def setup_posts_ui(): 31 | 32 | def get_editor(): 33 | editor = os.environ.get('GROWL_EDITOR') 34 | if not editor: 35 | editor = os.environ.get('EDITOR') 36 | if not editor: 37 | editor = 'vi' 38 | return editor 39 | 40 | def launch_editor(content = ''): 41 | fn = None 42 | try: 43 | fid, fn = tempfile.mkstemp('.post', 'growl_', None, True) 44 | f = open(fn, 'w') 45 | f.write(content) 46 | f.close() 47 | 48 | mod_time_start = os.stat(fn).st_mtime 49 | rcode = subprocess.call([get_editor(), fn]) 50 | mod_time_end = os.stat(fn).st_mtime 51 | 52 | hash_org = hashlib.sha256(content).digest() 53 | f = open(fn, 'r') 54 | content = f.read() 55 | f.close() 56 | hash_new = hashlib.sha256(content).digest() 57 | 58 | if (rcode != 0 or 59 | mod_time_end == mod_time_start or 60 | hash_org == hash_new): 61 | return None 62 | 63 | # only delete temp file if anything went ok 64 | return content 65 | finally: 66 | if fn: 67 | os.unlink(fn) 68 | 69 | def raw_input_default(prompt, value = None): 70 | if value: 71 | readline.set_startup_hook(lambda: readline.insert_text(value)) 72 | try: 73 | return raw_input(prompt) 74 | finally: 75 | if value: 76 | readline.set_startup_hook(None) 77 | 78 | def mangle_url(url): 79 | ou = url 80 | url = url.lower() 81 | url = ''.join(c for c in url if c not in mangle_url.SP) 82 | url = url.replace('&', ' and ') 83 | url = url.replace('.', ' dot ') 84 | url = url.strip() 85 | url = url.replace(' ', '_') 86 | return urllib.quote(url) 87 | mangle_url.SP = '`~!@#$%^*()+={}[]|\\;:\'",<>/?' 88 | 89 | def create_new_post(self): 90 | TEMPLATE = textwrap.dedent(""" 91 | --- 92 | layout: post 93 | title: ??? 94 | categories: ??? 95 | --- 96 | """).strip() 97 | try: 98 | content = launch_editor(TEMPLATE) 99 | if content: 100 | # load yaml header 101 | mo = Template.RE_YAML.match(content) 102 | if mo and mo.groupdict().get('yaml'): 103 | meta = yaml.load(mo.groupdict().get('yaml')) 104 | title = meta.get('title') 105 | 106 | if title: 107 | title = mangle_url(title) 108 | 109 | tnow = datetime.datetime.now().timetuple() 110 | filename = time.strftime('%Y-%m-%d-', tnow) 111 | filename += title 112 | 113 | try: 114 | filename = raw_input_default('filename: ', filename) 115 | filename = os.path.join(self.POST_DIR, filename) 116 | f = open(filename, 'w') 117 | f.write(content) 118 | f.close() 119 | print 'created post: %s' % filename 120 | except KeyboardInterrupt: 121 | # save backup to temp file 122 | print '\nabort...' 123 | fid, fn = tempfile.mkstemp('.post', 'growl_', 124 | None, True) 125 | f = open(fn, 'w') 126 | f.write(content) 127 | f.close() 128 | else: 129 | print 'abort... (no title)' 130 | else: 131 | print 'abort...' 132 | except Exception, e: 133 | print 'can\'t create new post: %s' % e 134 | raise 135 | 136 | @wrap(Site.setupOptions) 137 | def setupOptions(forig, self, parser): 138 | forig(self, parser) 139 | parser.add_option('-n', '--newpost', 140 | action = 'store_true', dest = 'new_post', 141 | help = 'create new post') 142 | 143 | @wrap(Site.run) 144 | def site_run(forig, self): 145 | """ write all posts to the deploy directory. 146 | """ 147 | if self.options.new_post: 148 | create_new_post(self) 149 | else: 150 | forig(self) 151 | 152 | setup_posts_ui() 153 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | growl - python based, easy extendable, blog aware, static site generator 2 | ======================================================================== 3 | 4 | growl is a static website generator, which is heavily inspired from 5 | [jekyll](http://github.com/mojombo/jekyll/tree/master), 6 | and which shamelessly stole some really cool ideas from jekyll. 7 | 8 | nevertheless growl brings some nice features: 9 | 10 | * minimal dependencies 11 | * easy to install (and use? ;]) 12 | * easy to extend 13 | 14 | the [growl based site of my blog](http://github.com/xfire/downgrade/tree) 15 | is also available on github. 16 | 17 | 18 | installation 19 | ------------ 20 | 21 | ### prequisites 22 | 23 | the following basic packages are needed: 24 | 25 | > apt-get install python python-yaml 26 | 27 | all other is optional depending on you own needs. 28 | 29 | I recommend using [jinja2][jinja2] as the templating engine. growl will 30 | use [jinja2][jinja2] as default, if it is installed. 31 | 32 | > apt-get install python-jinja2 33 | 34 | you are free to use some other templating engine like [django][django], 35 | [mako][mako] or [cheetah][cheetah]. for examples how to 36 | configure them, see [extending growl](#extending_growl). 37 | 38 | ### finish the installation 39 | 40 | after installing all needed packages, you can use `growl.py` 41 | directly or copy it to a place which is in your `$PATH`. 42 | 43 | > ./growl.py ... 44 | > cp growl.py /usr/local/bin 45 | 46 | 47 | usage 48 | ----- 49 | 50 | simply call `growl.py` with the source directory: 51 | 52 | > growl.py my.site 53 | 54 | growl will then generate the output in the directory `my.site/_deploy`. 55 | if you want growl to spit the generated stuff into another directory, 56 | simply specify this director as second parameter. 57 | 58 | > growl.py my.site /tmp/my.site.output 59 | 60 | ### options 61 | 62 | * `--serve[:port]` (default port: 8080) 63 | 64 | generate the site to the deploy directory and then start a simple 65 | webserver. this is intended to be used for testing purposes only. 66 | 67 | > growl.py --serve -1234 my.site 68 | 69 | * `--deploy` 70 | 71 | trigger deploy process. this does nothing per default, but you can 72 | add actions using hooks. (see `_hooks/deploy_rsync.py`) 73 | 74 | * `--autoreload` or `-r` 75 | 76 | relaunch growl each time a modification occurs on the content files. 77 | (*Please note*: you will be unable to use Growl's built-in webserver 78 | when you use --autoreload. To see your changes in the browser, 79 | simply navigate to your `_deploy` directory and open up a new 80 | terminal instance and use Python's built-in webserver in conjunction 81 | with Growl.) 82 | 83 | > growl.py -r my.site 84 | 85 | then, in a new terminal window: 86 | 87 | > cd my.site/_deploy/ 88 | 89 | > python -m SimpleHTTPServer 90 | 91 | and point your browser to 0.0.0.0:8000. 92 | 93 | 94 | input data 95 | ---------- 96 | 97 | growl will ignore all files and directories which starts with 98 | a `.` or a `_`. (this can be changed via `Site.IGNORE`, see 99 | [extending growl](#extending_growl)) 100 | 101 | all files ending with `_` or a transformer extension (`Config.transformers`) 102 | are processed as **pages**. in these cases, the ending will be striped from 103 | the filename. 104 | 105 | e.g. 106 | 107 | * `index.html_` -> `index.html` 108 | * `atom.xml_` -> `atom.xml` 109 | * `somefile.txt.markdown` -> `somefile.txt` 110 | 111 | some directories beginning with an `_` are special to growl: 112 | 113 | * `_deploy/` the default deploy directory 114 | * `_layout/` contains your site layouts 115 | * `_posts/` contains your posts 116 | * `_hooks/` contains all your hooks (see [extending growl](#extending_growl)) 117 | * `_libs/` contains third party code (see [extending growl](#extending_growl)) 118 | 119 | all **pages** and **posts** optionally can have an [yaml][yaml] header. this 120 | header must begin and end with a line containing 3 hyphen. e.g. 121 | 122 | --- 123 | layout: post 124 | title: my post title 125 | category: spam, eggs 126 | --- 127 | 128 | all data defined in this header will be attached to the corresponding object 129 | and can be accessed in your template code. an example in [jinja2][jinja2] may 130 | look like 131 | 132 | 137 | 138 | in the context of your template, you have access to one or more of the following 139 | objects. 140 | 141 | ### site 142 | 143 | this holds the site wide informations. 144 | 145 | * `site.now` 146 | 147 | current date and time when you run growl. this is a python 148 | [datetime](http://docs.python.org/library/datetime.html#datetime-objects) object. 149 | 150 | {{ site.now.year }}-{{site.now.month}} 151 | 152 | * `site.posts` 153 | 154 | a chronological list of all posts. 155 | 156 | {% for post in site.posts|reverse|slice(8) %} 157 | {{ post.content }} 158 | {% endfor %} 159 | 160 | * `site.unpublished_posts` 161 | 162 | a chronological list of all unpublished posts. e.g. all posts which set `publish` to 163 | false. 164 | 165 | * `site.categories` 166 | 167 | a dictionary mapping category <-> posts. 168 | 169 | 180 | 181 | ### page 182 | 183 | * `page.url` 184 | 185 | the relative url to the page. 186 | 187 | * `page.transformed` 188 | 189 | the transformed content. no layouts are applied here. 190 | 191 | ### post 192 | 193 | * `post.date` 194 | 195 | a datetime object with the publish date of the post 196 | 197 | * `post.url` 198 | 199 | the relative url to the post 200 | 201 | * `post.publish` 202 | 203 | if set to false, the post will be generated, but is not in the list of `site.posts`. instead 204 | it's in the `site.unpublished_posts` list. 205 | 206 | if `publish` is not set, growl will assume this as true and the post will be normally published. 207 | 208 | * `post.content` 209 | 210 | the transformed content. exactly the layout specified in the [yaml][yaml] header is applied. 211 | (no recursive applying) 212 | 213 | * `post.transformed` 214 | 215 | the transformed content. no layouts are applied here. 216 | 217 | 218 | 219 | extending growl 220 | --------------- 221 | 222 | growl is very easy extendable via python code placed in the `_hooks` and 223 | `_libs` directory. 224 | 225 | if the `_libs` directory exists, it is added to the python module search path 226 | (`sys.path`), so python modules dropped there will be available in the code. 227 | 228 | all files in the `_hooks` directory, which ends with `.py`, are executed 229 | directly in the global scope of the growl.py file. thus a hook can freely 230 | shape growls code at will. growl loves that! ;) 231 | 232 | here are some examples of what can be done. but you sure can imagine other 233 | cool things. 234 | 235 | 236 | ### configuring template engines 237 | 238 | 239 | 240 | ### register new transformers 241 | 242 | new transformers can be registered in the `Config` class by adding a 243 | filename extension <-> transformation function mapping to the `transformers` 244 | attribute. here's an example for markdown2: 245 | 246 | import markdown2 247 | import functools 248 | 249 | Config.transformers['noop'] = lambda source: source 250 | Config.transformers['markdown2'] = functools.partial( 251 | markdown2.markdown, 252 | extras={'code-color': {"noclasses": True}}) 253 | 254 | the transformation function must return the transformed source text which is given 255 | as the only parameter. so if you need to add more parameters to your 256 | transformation function, best use the [functools](http://docs.python.org/library/functools.html) 257 | module as you see in the example above. 258 | 259 | 260 | 261 | ### change which files will be ignored 262 | 263 | growl decides to ignore files which filenames start with one of the tokens 264 | defined in `Site.IGNORE`. so a hook with the following content will make 265 | growl ignore all files begining with `.`, `_` and `foo`. 266 | 267 | Site.IGNORE += ('foo',) 268 | 269 | 270 | 271 | ### define global template context content 272 | 273 | simply add your content to `Site.CONTEXT` like these examples: 274 | 275 | Site.CONTEXT.author = 'Rico Schiekel' 276 | Site.CONTEXT.site = AttrDict(author = 'Rico Schiekel') 277 | 278 | note: `Site.CONTEXT.site` has to be an `AttrDict` instance! 279 | 280 | 281 | 282 | ### add some verbosity 283 | 284 | as an example, we would display the currently processed post, while 285 | growl chomp your input. 286 | 287 | create a new file (e.g. `verbose.py`) in the `_hooks` directory with the 288 | following content: 289 | 290 | @wrap(Post.write) 291 | def verbose_post_write(forig, self): 292 | print 'post: %s - %s\n' % (self.date.strftime('%Y-%m-%d'), self.title) 293 | return forig(self) 294 | 295 | growl offers the helper decorator `wrap`, which wraps an existing method 296 | of a class with a new one. the original method is given to your new function 297 | as the first parameter (`forig`). 298 | 299 | 300 | 301 | 302 | bug reporting 303 | ------------- 304 | 305 | please report bugs [here](http://github.com/xfire/growl/issues). 306 | 307 | 308 | license 309 | ------- 310 | [GPLv2](http://www.gnu.org/licenses/gpl-2.0.html) 311 | 312 | 313 | 314 | [jinja2]: http://jinja.pocoo.org/2/ "jinja2" 315 | [django]: http://www.djangoproject.com/ "django" 316 | [mako]: http://www.makotemplates.org/ "mako" 317 | [cheetah]: http://www.cheetahtemplate.org/ "cheetah" 318 | [yaml]: http://www.yaml.org/ "yaml" 319 | -------------------------------------------------------------------------------- /growl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # vim:syntax=python:sw=4:ts=4:expandtab 4 | # 5 | # Copyright (C) 2012 Rico Schiekel (fire at downgra dot de) 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License version 2 9 | # as published by the Free Software Foundation 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program; if not, write to the Free Software 18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, 19 | # MA 02110-1301, USA. 20 | # 21 | 22 | __author__ = 'Rico Schiekel ' 23 | __copyright__ = 'Copyright (C) 2012 Rico Schiekel' 24 | __license__ = 'GPLv2' 25 | __version__ = '0.3' 26 | 27 | 28 | import os 29 | import sys 30 | import re 31 | import shutil 32 | import datetime 33 | import time 34 | import collections 35 | import itertools 36 | import functools 37 | import inspect 38 | from optparse import OptionParser 39 | 40 | import yaml 41 | 42 | 43 | def renderTemplate(template, context): 44 | raise NotImplementedError('no template engine configured!') 45 | 46 | 47 | try: 48 | import jinja2 49 | 50 | jinja2_env = jinja2.Environment() 51 | 52 | def renderTemplate(template, context): 53 | template = template.decode("utf8") 54 | return jinja2_env.from_string(template).render(context) 55 | 56 | def templateFilter(func): 57 | """ decorator to easily create jinja2 filters 58 | """ 59 | jinja2_env.filters[func.__name__] = func 60 | except ImportError: 61 | pass 62 | 63 | 64 | def wrap(orig_func): 65 | """ decorator to wrap an existing method of a class. 66 | e.g. 67 | 68 | @wrap(Post.write) 69 | def verbose_write(forig, self): 70 | print 'generating post: %s (from: %s)' % (self.title, 71 | self.filename) 72 | return forig(self) 73 | 74 | the first parameter of the new function is the the original, 75 | overwritten function ('forig'). 76 | """ 77 | 78 | # har, some funky python magic NOW! 79 | 80 | def outer(new_func): 81 | 82 | @functools.wraps(orig_func) 83 | def wrapper(*args, **kwargs): 84 | return new_func(orig_func, *args, **kwargs) 85 | 86 | if inspect.ismethod(orig_func): 87 | setattr(orig_func.im_class, orig_func.__name__, wrapper) 88 | return wrapper 89 | return outer 90 | 91 | 92 | class AttrDict(dict): 93 | """ dictionary which provides its items as attributes. 94 | """ 95 | 96 | def __getattr__(self, name): 97 | return self[name] 98 | 99 | def __setattr__(self, name, value): 100 | self[name] = value 101 | 102 | def copy(self): 103 | return AttrDict(super(AttrDict, self).copy()) 104 | 105 | 106 | class Config(object): 107 | """ base class providing some static configuration values. 108 | """ 109 | 110 | LIB_DIR = HOOK_DIR = '' 111 | 112 | @classmethod 113 | def updateconfig(cls, base, deploy): 114 | cls.transformers = {} 115 | cls.BASE_DIR = base 116 | cls.DEPLOY_DIR = deploy 117 | cls.LAYOUT_DIR = os.path.join(base, '_layout') 118 | cls.HOOK_DIR = os.path.join(base, '_hooks') 119 | cls.LIB_DIR = os.path.join(base, '_libs') 120 | cls.POST_FILE_EXT = '.html' 121 | cls.ARTICLE_FILE_EXT = '.html' 122 | 123 | 124 | class Template(Config): 125 | """ abstract template base class providing support for an 126 | yaml header, transforming based on file extension, 127 | rendering (only using current layout if defined) and 128 | layouting (applying all layouts). 129 | """ 130 | 131 | RE_YAML = re.compile(r'(^---\s*$(?P.*?)^---\s*$)?(?P.*)', 132 | re.M | re.S) 133 | 134 | def __init__(self, filename, layouts, context): 135 | super(Template, self).__init__() 136 | self.filename = filename 137 | self.layouts = layouts 138 | self.context = context.copy() 139 | self.context.layout = None 140 | self.read_yaml() 141 | 142 | def read_yaml(self): 143 | """ read yaml header and remove the header from content 144 | """ 145 | self._content = file(self.filename, 'r').read() 146 | 147 | mo = self.RE_YAML.match(self._content) 148 | if mo and mo.groupdict().get('yaml'): 149 | self.context.update(yaml.load(mo.groupdict().get('yaml'))) 150 | self._content = mo.groupdict().get('content') 151 | 152 | def transform(self): 153 | """ do transformation based on filename extension. 154 | e.g. do markdown or textile transformations 155 | """ 156 | ext = os.path.splitext(self.filename)[-1][1:] 157 | t = self.transformers.get(ext, lambda x: x) 158 | return t(self._content) 159 | 160 | def render(self): 161 | """ render content, so transforming and then 162 | apply current layout. 163 | """ 164 | ctx = self.context.copy() 165 | ctx.content = renderTemplate(self.transform(), ctx) 166 | layout = self.layouts.get(ctx.layout) 167 | if layout: 168 | return renderTemplate(layout.content, ctx) 169 | else: 170 | return ctx.content 171 | 172 | def layout(self): 173 | """ layout content, so transforming and then applying 174 | all layouts. 175 | """ 176 | ctx = self.context.copy() 177 | ctx.content = self.render() 178 | layout = self.layouts.get(ctx.layout) 179 | if layout: 180 | layout = self.layouts.get(layout.layout) 181 | 182 | while layout != None: 183 | ctx.content = renderTemplate(layout.content, ctx) 184 | layout = self.layouts.get(layout.layout) 185 | 186 | return ctx.content 187 | 188 | def write(self, path, content): 189 | """ write content to path in deploy directory. 190 | """ 191 | fname = os.path.join(self.DEPLOY_DIR, path) 192 | dirname = os.path.dirname(fname) 193 | if not os.path.isdir(dirname): 194 | os.makedirs(dirname) 195 | f = file(fname, 'w') 196 | f.write(content.encode("utf8")) 197 | f.close() 198 | 199 | def __getattr__(self, name): 200 | if not name in self.context: 201 | raise AttributeError("'%s' object has no attribute '%s'" % 202 | (self.__class__.__name__, name)) 203 | return self.context[name] 204 | 205 | @property 206 | def transformed(self): 207 | return self.transform() 208 | 209 | 210 | class Layout(Template): 211 | """ a layout template from _layouts/ directory. 212 | """ 213 | 214 | def __init__(self, filename, context): 215 | super(Layout, self).__init__(filename, {}, context) 216 | 217 | base = os.path.basename(filename) 218 | ext = os.path.splitext(base) 219 | self.name = ext[0] 220 | 221 | @property 222 | def layout(self): 223 | return self.context.get('layout') 224 | 225 | @property 226 | def content(self): 227 | return self.transform() 228 | 229 | 230 | class Page(Template): 231 | """ a page template which should be transformed. e.g. files which 232 | filename ends with an '_' or an transformer file extension. 233 | """ 234 | 235 | TRANSFORM = ('_', ) 236 | 237 | def __init__(self, filename, layout, context): 238 | super(Page, self).__init__(filename, layout, context) 239 | 240 | self.context.page = self 241 | 242 | @property 243 | def url(self): 244 | return self.path.replace(os.path.sep, '/') 245 | 246 | @property 247 | def urlparts(self): 248 | return self.url.split("/") 249 | 250 | @property 251 | def root(self): 252 | return "../" * self.url.count("/") 253 | 254 | @property 255 | def path(self): 256 | path = os.path.abspath(self.filename) 257 | npath, ext = os.path.splitext(path) 258 | if self.filename[-1] in Page.TRANSFORM: 259 | path = path[:-1] 260 | elif ext and ext[1:] in self.transformers: 261 | path = npath 262 | path = path.replace(os.path.abspath(self.BASE_DIR), '', 1) 263 | return path.lstrip(os.path.sep) 264 | 265 | @property 266 | def content(self): 267 | return self.render() 268 | 269 | def write(self): 270 | return super(Page, self).write(self.path, self.layout()) 271 | 272 | @staticmethod 273 | def transformable(filename): 274 | """ return true, if the file is transformable. that means the 275 | filename ends with a character from self.TRANSFORM or 276 | self.transformers. 277 | """ 278 | ext = os.path.splitext(filename)[-1] 279 | return ((filename[-1] in Page.TRANSFORM) or 280 | (ext and ext[1:] in Page.transformers)) 281 | 282 | 283 | class Site(Config): 284 | """ controls the site and holds the global context object. the context 285 | object contains all layouts, all posts and categories. 286 | hooks can be used to configure the context. 287 | """ 288 | 289 | CONTEXT = AttrDict() 290 | IGNORE = ('_', '.') 291 | 292 | def __init__(self): 293 | super(Site, self).__init__() 294 | 295 | if not self.LIB_DIR in sys.path and os.path.isdir(self.LIB_DIR): 296 | sys.path.append(self.LIB_DIR) 297 | 298 | self.layouts = {} 299 | 300 | self.hooks() 301 | 302 | self.context = Site.CONTEXT.copy() 303 | if not 'site' in self.context: 304 | self.context.site = AttrDict() 305 | 306 | self.context.site.now = datetime.datetime.now() 307 | 308 | def hooks(self): 309 | """ load all available hooks from the _hooks/ directory. 310 | """ 311 | if os.path.isdir(self.HOOK_DIR): 312 | for f in sorted(self.ignoreFilter(os.listdir(self.HOOK_DIR))): 313 | if f.endswith('.py'): 314 | execfile(os.path.join(self.HOOK_DIR, f), globals()) 315 | 316 | def prepare(self): 317 | """ read all layouts 318 | """ 319 | self.read_layouts() 320 | 321 | def run(self): 322 | """ generate the site content to the deploy directory. 323 | """ 324 | self.write_site_content() 325 | 326 | if options.serve != None: 327 | try: 328 | options.serve = (options.serve).strip('-') 329 | port = int(options.serve) 330 | site.serve(port) 331 | except ValueError: 332 | print 'Invalid Port: %s' % options.serve 333 | 334 | def read_layouts(self): 335 | if os.path.isdir(self.LAYOUT_DIR): 336 | self.layouts = [Layout(os.path.join(self.LAYOUT_DIR, f), 337 | self.context) 338 | for f in self.ignoreFilter(os.listdir( 339 | self.LAYOUT_DIR))] 340 | self.layouts = dict((l.name, l) for l in self.layouts) 341 | 342 | def write_site_content(self): 343 | """ copy site content to deploy directory. 344 | 345 | ignoring all files and directories, if their filename 346 | begins with a token defined in IGNORE. 347 | 348 | files with and filename ending with an token defined in 349 | TRANSFORM are transformed via the Page class. all other 350 | files are simple copied. 351 | """ 352 | 353 | for root, dirs, files in os.walk(self.BASE_DIR): 354 | base = root.replace(self.BASE_DIR, '') 355 | base = base.lstrip(os.path.sep) 356 | 357 | for d in self.ignoreFilter(dirs): 358 | nd = os.path.join(self.DEPLOY_DIR, base, d) 359 | if not os.path.isdir(nd): 360 | os.makedirs(nd) 361 | dirs[:] = self.ignoreFilter(dirs) 362 | 363 | for f in self.ignoreFilter(files): 364 | if Page.transformable(f): 365 | Page(os.path.join(root, f), 366 | self.layouts, 367 | self.context).write() 368 | else: 369 | path = os.path.abspath(root) 370 | path = path.replace(os.path.abspath(self.BASE_DIR), '', 1) 371 | path = path.lstrip(os.path.sep) 372 | path = os.path.join(self.DEPLOY_DIR, path) 373 | if not os.path.isdir(path): 374 | os.makedirs(path) 375 | shutil.copy(os.path.join(root, f), os.path.join(path, f)) 376 | 377 | def serve(self, port): 378 | """ serve the deploy directory with a very simple, cgi 379 | capable web server on 0.0.0.0:. 380 | """ 381 | from BaseHTTPServer import HTTPServer 382 | from CGIHTTPServer import CGIHTTPRequestHandler 383 | os.chdir(self.DEPLOY_DIR) 384 | httpd = HTTPServer(('', int(port)), CGIHTTPRequestHandler) 385 | sa = httpd.socket.getsockname() 386 | print "Serving HTTP on", sa[0], "port", sa[1], "..." 387 | try: 388 | httpd.serve_forever() 389 | except KeyboardInterrupt: 390 | pass 391 | 392 | def ignoreFilter(self, seq): 393 | """ filter out files starting with self.IGNORE tokens 394 | """ 395 | 396 | def ignore_filter(item): 397 | for ign in self.IGNORE: 398 | if item.startswith(ign): 399 | return False 400 | return True 401 | return itertools.ifilter(ignore_filter, seq) 402 | 403 | def files_changed(self, path, extensions): 404 | """ return true if the files have changed since the last check 405 | """ 406 | def file_times(path): 407 | """ return the last time files have been modified 408 | """ 409 | for root, dirs, files in os.walk(path): 410 | dirs[:] = [x for x in dirs if x[0] != '.' and x != '_deploy'] 411 | for file in files: 412 | if any(file.endswith(ext) for ext in extensions): 413 | try: 414 | yield os.stat(os.path.join(root, file)).st_mtime 415 | except: 416 | yield None 417 | 418 | global LAST_MTIME 419 | mtime = max(file_times(path)) 420 | if mtime > LAST_MTIME: 421 | LAST_MTIME = mtime 422 | return True 423 | return False 424 | 425 | def get_extensions(self, path): 426 | """ get all filename extensions and ignore the `_deploy` directory 427 | """ 428 | exts = [] 429 | for root, dirs, files in os.walk(path): 430 | dirs[:] = [x for x in dirs if x[0] != '.' and x != '_deploy'] 431 | for file in files: 432 | ext = os.path.splitext(file)[-1][1:] 433 | exts.append(ext) 434 | return set(exts) 435 | 436 | def setupOptions(self, parser): 437 | parser.add_option('--serve', 438 | action = 'store', dest = 'serve', 439 | metavar = 'PORT', 440 | help = 'Start web server') 441 | 442 | parser.set_defaults(version = False) 443 | parser.add_option('-v', '--version', 444 | action = 'store_true', dest = 'version', 445 | help = 'Output version information and exit') 446 | parser.add_option('-r', '--autoreload', 447 | action = 'store_true', dest = 'autoreload', 448 | help = 'Relaunch Growl each time a modification' 449 | ' occurs on the content files.') 450 | 451 | 452 | if __name__ == '__main__': 453 | DEFAULT_PORT = 8080 454 | LAST_MTIME = 0 455 | parser = OptionParser(usage = 'syntax: %prog [options] [to]') 456 | 457 | base = deploy_path = None 458 | args = sys.argv[1:] 459 | 460 | for arg in sys.argv[:0:-1]: 461 | if not arg.startswith('-'): 462 | if not deploy_path: 463 | deploy_path = arg 464 | elif not base: 465 | base = arg 466 | elif arg == '--': 467 | break 468 | 469 | if not base and deploy_path: 470 | base = deploy_path 471 | deploy_path = os.path.join(base, '_deploy') 472 | 473 | if base and os.path.isdir(base): 474 | Config.updateconfig(base, deploy_path) 475 | 476 | site = Site() 477 | 478 | site.setupOptions(parser) 479 | (options, args) = parser.parse_args(args) 480 | 481 | if options.version: 482 | print 'growl version %s - %s (%s)' % (__version__, 483 | __copyright__, 484 | __license__) 485 | sys.exit(0) 486 | 487 | if not base: 488 | parser.error('"from" parameter missing!') 489 | 490 | if not os.path.isdir(base): 491 | print 'error: invalid directory: %s' % base 492 | sys.exit(2) 493 | 494 | try: 495 | import markdown 496 | Config.transformers.setdefault('markdown', markdown.markdown) 497 | except ImportError: 498 | pass 499 | 500 | try: 501 | import textile 502 | Config.transformers.setdefault('textile', textile.textile) 503 | except ImportError: 504 | pass 505 | 506 | try: 507 | # set jinja2 loader to enable template inheritance 508 | jinja2_env.loader = jinja2.FileSystemLoader(site.LAYOUT_DIR) 509 | except NameError: 510 | pass 511 | 512 | site.options = options 513 | 514 | extensions = site.get_extensions(base) 515 | 516 | if options.autoreload: 517 | while True: 518 | try: 519 | if site.files_changed(base, extensions): 520 | site.prepare() 521 | site.run() 522 | time.sleep(1) 523 | except KeyboardInterrupt: 524 | break 525 | else: 526 | site.prepare() 527 | site.run() 528 | --------------------------------------------------------------------------------