├── 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 |
133 | {% for post in site.posts|reverse %}
134 | - {{ post.title }} - {{ post.date }}
135 | {% endfor %}
136 |
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 |
170 | {% for cat in site.categories %}
171 | - {{ cat }}
172 |
173 | {% for post in site.categories.get(cat) %}
174 | - {{ post.title }} - {{ post.date }}
175 | {% endfor %}
176 |
177 |
178 | {% endfor %}
179 |
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 |
--------------------------------------------------------------------------------