diff options
| -rwxr-xr-x | libexec/poole | 746 |
1 files changed, 746 insertions, 0 deletions
diff --git a/libexec/poole b/libexec/poole new file mode 100755 index 0000000..ea66ccb --- /dev/null +++ b/libexec/poole | |||
| @@ -0,0 +1,746 @@ | |||
| 1 | #!/usr/bin/env python | ||
| 2 | # -*- coding: utf-8 -*- | ||
| 3 | |||
| 4 | # ============================================================================= | ||
| 5 | # | ||
| 6 | # Poole - A damn simple static website generator. | ||
| 7 | # Copyright (C) 2012 Oben Sonne <obensonne@googlemail.com> | ||
| 8 | # | ||
| 9 | # This file is part of Poole. | ||
| 10 | # | ||
| 11 | # Poole is free software: you can redistribute it and/or modify | ||
| 12 | # it under the terms of the GNU General Public License as published by | ||
| 13 | # the Free Software Foundation, either version 3 of the License, or | ||
| 14 | # (at your option) any later version. | ||
| 15 | # | ||
| 16 | # Poole is distributed in the hope that it will be useful, | ||
| 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| 19 | # GNU General Public License for more details. | ||
| 20 | # | ||
| 21 | # You should have received a copy of the GNU General Public License | ||
| 22 | # along with Poole. If not, see <http://www.gnu.org/licenses/>. | ||
| 23 | # | ||
| 24 | # ============================================================================= | ||
| 25 | |||
| 26 | from __future__ import with_statement | ||
| 27 | |||
| 28 | import codecs | ||
| 29 | import glob | ||
| 30 | import imp | ||
| 31 | import optparse | ||
| 32 | import os | ||
| 33 | from os.path import join as opj | ||
| 34 | from os.path import exists as opx | ||
| 35 | import re | ||
| 36 | import shutil | ||
| 37 | import StringIO | ||
| 38 | import sys | ||
| 39 | import traceback | ||
| 40 | import urlparse | ||
| 41 | |||
| 42 | from SimpleHTTPServer import SimpleHTTPRequestHandler | ||
| 43 | from BaseHTTPServer import HTTPServer | ||
| 44 | |||
| 45 | try: | ||
| 46 | import markdown | ||
| 47 | except ImportError: | ||
| 48 | print("abort : need python-markdown, get it from " | ||
| 49 | "http://www.freewisdom.org/projects/python-markdown/Installation") | ||
| 50 | sys.exit(1) | ||
| 51 | |||
| 52 | HERE = os.path.dirname(os.path.realpath(__file__)) | ||
| 53 | |||
| 54 | THEME_DIR = opj(HERE, 'themes') | ||
| 55 | |||
| 56 | THEME_NAMES = ['minimal'] + [ | ||
| 57 | os.path.basename(x) | ||
| 58 | for x in glob.glob(opj(THEME_DIR, '*')) | ||
| 59 | if os.path.isdir(x) | ||
| 60 | ] | ||
| 61 | |||
| 62 | # ============================================================================= | ||
| 63 | # init site | ||
| 64 | # ============================================================================= | ||
| 65 | |||
| 66 | EXAMPLE_FILES = { | ||
| 67 | |||
| 68 | "page.html": """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> | ||
| 69 | <html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en"> | ||
| 70 | <head> | ||
| 71 | <meta http-equiv="Content-Type" content="text/html; charset={{ __encoding__ }}" /> | ||
| 72 | <title>poole - {{ hx(page["title"]) }}</title> | ||
| 73 | <meta name="description" content="{{ hx(page.get("description", "a poole site")) }}" /> | ||
| 74 | <meta name="keywords" content="{{ hx(page.get("keywords", "poole")) }}" /> | ||
| 75 | <style type="text/css"> | ||
| 76 | body { | ||
| 77 | font-family: sans; | ||
| 78 | width: 800px; | ||
| 79 | margin: 1em auto; | ||
| 80 | color: #2e3436; | ||
| 81 | } | ||
| 82 | div#box { | ||
| 83 | } | ||
| 84 | div#header, div#menu, div#content, div#footer { | ||
| 85 | padding: 1em; | ||
| 86 | } | ||
| 87 | div#menu { | ||
| 88 | background-color: #eeeeec; | ||
| 89 | padding: 0.6em 0 0.6em 0; | ||
| 90 | } | ||
| 91 | #menu span { | ||
| 92 | font-weight: bold; | ||
| 93 | padding: 0.6em; | ||
| 94 | } | ||
| 95 | #menu span.current { | ||
| 96 | background-color: #ffffff; | ||
| 97 | border: 1px solid #eeeeec; | ||
| 98 | } | ||
| 99 | #menu a { | ||
| 100 | color: #000000; | ||
| 101 | text-decoration: none; | ||
| 102 | } | ||
| 103 | div#footer { | ||
| 104 | color: gray; | ||
| 105 | text-align: center; | ||
| 106 | font-size: small; | ||
| 107 | } | ||
| 108 | div#footer a { | ||
| 109 | color: gray; | ||
| 110 | text-decoration: none; | ||
| 111 | } | ||
| 112 | pre { | ||
| 113 | border: dotted black 1px; | ||
| 114 | background: #eeeeec; | ||
| 115 | font-size: small; | ||
| 116 | padding: 1em; | ||
| 117 | } | ||
| 118 | </style> | ||
| 119 | </head> | ||
| 120 | <body> | ||
| 121 | <div id="box"> | ||
| 122 | <div id="header"> | ||
| 123 | <h1>a poole site</h1> | ||
| 124 | <h2>{{ hx(page["title"]) }}</h2> | ||
| 125 | </div> | ||
| 126 | <div id="menu"> | ||
| 127 | <!--% | ||
| 128 | mpages = [p for p in pages if "menu-position" in p] | ||
| 129 | mpages.sort(key=lambda p: int(p["menu-position"])) | ||
| 130 | entry = '<span class="%s"><a href="%s">%s</a></span>' | ||
| 131 | for p in mpages: | ||
| 132 | style = "current" if p["title"] == page["title"] else "" | ||
| 133 | print(entry % (style, p["url"], hx(p["title"]))) | ||
| 134 | %--> | ||
| 135 | </div> | ||
| 136 | <div id="content">{{ __content__ }}</div> | ||
| 137 | </div> | ||
| 138 | <div id="footer"> | ||
| 139 | Built with <a href="http://bitbucket.org/obensonne/poole">Poole</a> | ||
| 140 | · | ||
| 141 | Licensed as <a href="http://creativecommons.org/licenses/by-sa/3.0">CC-SA</a> | ||
| 142 | · | ||
| 143 | <a href="http://validator.w3.org/check?uri=referer">Validate me</a> | ||
| 144 | </div> | ||
| 145 | </body> | ||
| 146 | </html> | ||
| 147 | """, | ||
| 148 | |||
| 149 | # ----------------------------------------------------------------------------- | ||
| 150 | |||
| 151 | opj("input", "index.md"): """ | ||
| 152 | title: home | ||
| 153 | menu-position: 0 | ||
| 154 | --- | ||
| 155 | |||
| 156 | ## Welcome to Poole | ||
| 157 | |||
| 158 | In Poole you write your pages in [markdown][md]. It's easier to write | ||
| 159 | markdown than HTML. | ||
| 160 | |||
| 161 | Poole is made for simple websites you just want to get done, without installing | ||
| 162 | a bunch of requirements and without learning a template engine. | ||
| 163 | |||
| 164 | In a build, Poole copies every file from the *input* directory to the *output* | ||
| 165 | directory. During that process every markdown file (ending with *md*, *mkd*, | ||
| 166 | *mdown* or *markdown*) is converted to HTML using the project's `page.html` | ||
| 167 | as a skeleton. | ||
| 168 | |||
| 169 | [md]: http://daringfireball.net/projects/markdown/ | ||
| 170 | """, | ||
| 171 | |||
| 172 | # ----------------------------------------------------------------------------- | ||
| 173 | |||
| 174 | opj("input", "logic.md"): """ | ||
| 175 | menu-position: 4 | ||
| 176 | --- | ||
| 177 | Poole has basic support for content generation using Python code inlined in | ||
| 178 | page files. This is everything but a clear separation of logic and content but | ||
| 179 | for simple sites this is just a pragmatic way to get things done fast. | ||
| 180 | For instance the menu on this page is generated by some inlined Python code in | ||
| 181 | the project's `page.html` file. | ||
| 182 | |||
| 183 | Just ignore this feature if you don't need it :) | ||
| 184 | |||
| 185 | Content generation by inlined Python code is good to add some zest to your | ||
| 186 | site. If you use it a lot, you better go with more sophisticated site | ||
| 187 | generators like [Hyde](http://ringce.com/hyde). | ||
| 188 | """, | ||
| 189 | |||
| 190 | # ----------------------------------------------------------------------------- | ||
| 191 | |||
| 192 | opj("input", "layout.md"): """ | ||
| 193 | menu-position: 3 | ||
| 194 | --- | ||
| 195 | Every page of a poole site is based on *one global template file*, `page.html`. | ||
| 196 | All you need to adjust the site layout is to edit the page template | ||
| 197 | `page.html`. | ||
| 198 | """, | ||
| 199 | |||
| 200 | opj("input", "blog.md"): """ | ||
| 201 | menu-position: 10 | ||
| 202 | --- | ||
| 203 | Poole has basic blog support. If an input page's file name has a structure like | ||
| 204 | `page-title.YYYY-MM-DD.post-title.md`, e.g. `blog.2010-02-27.read_this.md`, | ||
| 205 | Poole recognizes the date and post title and sets them as attributes of the | ||
| 206 | page. These attributes can then be used to generate a list of blog posts: | ||
| 207 | |||
| 208 | <!--% | ||
| 209 | from datetime import datetime | ||
| 210 | posts = [p for p in pages if "post" in p] # get all blog post pages | ||
| 211 | posts.sort(key=lambda p: p.get("date"), reverse=True) # sort post pages by date | ||
| 212 | for p in posts: | ||
| 213 | date = datetime.strptime(p.date, "%Y-%m-%d").strftime("%B %d, %Y") | ||
| 214 | print " * **[%s](%s)** - %s" % (p.post, p.url, date) # markdown list item | ||
| 215 | %--> | ||
| 216 | |||
| 217 | Have a look into `input/blog.md` to see how it works. Feel free to adjust it | ||
| 218 | to your needs. | ||
| 219 | """, | ||
| 220 | |||
| 221 | # ----------------------------------------------------------------------------- | ||
| 222 | |||
| 223 | opj("input", "blog.2013-04-08.Lorem_Ipsum.md") : """ | ||
| 224 | |||
| 225 | --- | ||
| 226 | ## {{ page["post"] }} | ||
| 227 | |||
| 228 | *Posted at | ||
| 229 | <!--% | ||
| 230 | from datetime import datetime | ||
| 231 | print datetime.strptime(page["date"], "%Y-%m-%d").strftime("%B %d, %Y") | ||
| 232 | %-->* | ||
| 233 | |||
| 234 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed sed pretium arcu. | ||
| 235 | Nullam eu leo ut justo egestas condimentum sed id dolor. In suscipit est eu | ||
| 236 | tellus lacinia congue. Nunc tincidunt posuere nibh vitae accumsan. Suspendisse | ||
| 237 | quis justo quis nulla rhoncus venenatis. Cum sociis natoque penatibus et magnis | ||
| 238 | dis parturient montes, nascetur ridiculus mus. Suspendisse potenti. | ||
| 239 | |||
| 240 | Nullam luctus tortor ac libero eleifend interdum nec eget dolor. Aliquam quis | ||
| 241 | massa metus, id fringilla odio. Fusce lobortis sollicitudin gravida. Donec | ||
| 242 | porttitor metus aliquam diam consectetur vitae tristique ligula aliquet. Nulla | ||
| 243 | facilisi. Mauris eleifend erat id velit eleifend facilisis. Proin orci lacus, | ||
| 244 | imperdiet eu mollis ac, cursus sit amet ligula. Ut id neque urna, sed dignissim | ||
| 245 | urna. Cras sit amet sodales orci. In at lacus dui. Duis mi neque, posuere ut | ||
| 246 | congue non, ornare a magna. Fusce massa ligula, vestibulum sed vulputate quis, | ||
| 247 | sodales at massa. | ||
| 248 | |||
| 249 | No-ASCII characters like `öäüß` are no problems as long as input files are | ||
| 250 | encoded in UTF8. | ||
| 251 | """, | ||
| 252 | |||
| 253 | # ----------------------------------------------------------------------------- | ||
| 254 | |||
| 255 | opj("input", "blog.2013-04-01.Holy_Grail.md"): """ | ||
| 256 | |||
| 257 | ## {{ page["post"] }} | ||
| 258 | |||
| 259 | *Posted at <!--{ page["date"] }-->.* | ||
| 260 | |||
| 261 | Knights of Ni, we are but simple travelers who seek the enchanter who lives | ||
| 262 | beyond these woods. A newt? Did you dress her up like this? On second thoughts, | ||
| 263 | let's not go there. It is a silly place. You don't vote for kings. Knights of | ||
| 264 | Ni, we are but simple travelers who seek the enchanter who lives beyond these | ||
| 265 | woods. | ||
| 266 | |||
| 267 | ### Bridgekeeper ### | ||
| 268 | |||
| 269 | Camelot! What do you mean? And this isn't my nose. This is a false one. Ah, now | ||
| 270 | we see the violence inherent in the system! | ||
| 271 | |||
| 272 | You don't frighten us, English pig-dogs! Go and boil your bottoms, sons of a | ||
| 273 | silly person! I blow my nose at you, so-called Ah-thoor Keeng, you and all your | ||
| 274 | silly English K-n-n-n-n-n-n-n-niggits! I don't want to talk to you no more, you | ||
| 275 | empty-headed animal food trough water! I fart in your general direction! Your | ||
| 276 | mother was a hamster and your father smelt of elderberries! Now leave before I | ||
| 277 | am forced to taunt you a second time! Shh! Knights, I bid you welcome to your | ||
| 278 | new home. Let us ride to Camelot! Now, look here, my good man. | ||
| 279 | |||
| 280 | ### What a strange ### | ||
| 281 | |||
| 282 | She looks like one. Why do you think that she is a witch? Look, my liege! Bring | ||
| 283 | her forward! | ||
| 284 | |||
| 285 | [Ni!](http://chrisvalleskey.com/fillerama/) | ||
| 286 | """, | ||
| 287 | } | ||
| 288 | |||
| 289 | def init(project, theme): | ||
| 290 | """Initialize a site project.""" | ||
| 291 | |||
| 292 | if not opx(project): | ||
| 293 | os.makedirs(project) | ||
| 294 | |||
| 295 | if os.listdir(project): | ||
| 296 | print("abort : project dir %s is not empty" % project) | ||
| 297 | sys.exit(1) | ||
| 298 | |||
| 299 | dir_in = opj(project, "input") | ||
| 300 | dir_out = opj(project, "output") | ||
| 301 | |||
| 302 | os.mkdir(dir_in) | ||
| 303 | os.mkdir(dir_out) | ||
| 304 | |||
| 305 | for fname, content in EXAMPLE_FILES.items(): | ||
| 306 | print('info: create example %r' % fname) | ||
| 307 | with open(opj(project, fname), 'w') as fp: | ||
| 308 | fp.write(content) | ||
| 309 | |||
| 310 | if theme != 'minimal': | ||
| 311 | shutil.copy(opj(THEME_DIR, theme, 'page.html'), project) | ||
| 312 | for fname in glob.glob(opj(THEME_DIR, theme, '*')): | ||
| 313 | print('info: copy theme data %r' % fname) | ||
| 314 | if os.path.basename(fname) == 'page.html': | ||
| 315 | continue | ||
| 316 | if os.path.isdir(fname): | ||
| 317 | shutil.copytree(fname, opj(dir_in, os.path.basename(fname))) | ||
| 318 | else: | ||
| 319 | shutil.copy(fname, dir_in) | ||
| 320 | |||
| 321 | print("success: initialized project") | ||
| 322 | |||
| 323 | # ============================================================================= | ||
| 324 | # build site | ||
| 325 | # ============================================================================= | ||
| 326 | |||
| 327 | MKD_PATT = r'\.(?:md|mkd|mdown|markdown)$' | ||
| 328 | |||
| 329 | def hx(s): | ||
| 330 | """ | ||
| 331 | Replace the characters that are special within HTML (&, <, > and ") | ||
| 332 | with their equivalent character entity (e.g., &). This should be | ||
| 333 | called whenever an arbitrary string is inserted into HTML (so in most | ||
| 334 | places where you use {{ variable }} in your templates). | ||
| 335 | |||
| 336 | Note that " is not special in most HTML, only within attributes. | ||
| 337 | However, since escaping it does not hurt within normal HTML, it is | ||
| 338 | just escaped unconditionally. | ||
| 339 | """ | ||
| 340 | if getattr(s, 'escaped', False): | ||
| 341 | return s | ||
| 342 | |||
| 343 | escape = { | ||
| 344 | "&": "&", | ||
| 345 | '"': """, | ||
| 346 | ">": ">", | ||
| 347 | "<": "<", | ||
| 348 | } | ||
| 349 | return ''.join(escape.get(c, c) for c in s) | ||
| 350 | |||
| 351 | class Page(dict): | ||
| 352 | """Abstraction of a source page.""" | ||
| 353 | |||
| 354 | _template = None # template dictionary | ||
| 355 | _opts = None # command line options | ||
| 356 | _pstrip = None # path prefix to strip from (non-virtual) page file names | ||
| 357 | |||
| 358 | _re_eom = re.compile(r'^---+ *\r?\n?$') | ||
| 359 | _re_vardef = re.compile(r'^([^\n:=]+?)[:=]((?:.|\n )*)', re.MULTILINE) | ||
| 360 | _sec_macros = "macros" | ||
| 361 | _modmacs = None | ||
| 362 | |||
| 363 | def __init__(self, fname, virtual=None, **attrs): | ||
| 364 | """Create a new page. | ||
| 365 | |||
| 366 | Page content is read from `fname`, except when `virtual` is given (a | ||
| 367 | string representing the raw content of a virtual page). | ||
| 368 | |||
| 369 | The filename refers to the page source file. For virtual pages, this | ||
| 370 | *must* be relative to a projects input directory. | ||
| 371 | |||
| 372 | Virtual pages may contain page attribute definitions similar to real | ||
| 373 | pages. However, it probably is easier to provide the attributes | ||
| 374 | directly. This may be done using arbitrary keyword arguments. | ||
| 375 | |||
| 376 | """ | ||
| 377 | super(Page, self).__init__() | ||
| 378 | |||
| 379 | self.update(self._template) | ||
| 380 | self.update(attrs) | ||
| 381 | |||
| 382 | self._virtual = virtual is not None | ||
| 383 | |||
| 384 | fname = opj(self._pstrip, fname) if virtual else fname | ||
| 385 | |||
| 386 | self["fname"] = fname | ||
| 387 | |||
| 388 | self["url"] = re.sub(MKD_PATT, ".html", fname) | ||
| 389 | self["url"] = self["url"][len(self._pstrip):].lstrip(os.path.sep) | ||
| 390 | self["url"] = self["url"].replace(os.path.sep, "/") | ||
| 391 | |||
| 392 | if virtual: | ||
| 393 | self.raw = virtual | ||
| 394 | else: | ||
| 395 | with codecs.open(fname, 'r', self._opts.input_enc) as fp: | ||
| 396 | self.raw = fp.readlines() | ||
| 397 | |||
| 398 | # split raw content into macro definitions and real content | ||
| 399 | vardefs = "" | ||
| 400 | self.source = "" | ||
| 401 | for line in self.raw: | ||
| 402 | if not vardefs and self._re_eom.match(line): | ||
| 403 | vardefs = self.source | ||
| 404 | self.source = "" # only macro defs until here, reset source | ||
| 405 | else: | ||
| 406 | self.source += line | ||
| 407 | |||
| 408 | for key, val in self._re_vardef.findall(vardefs): | ||
| 409 | key = key.strip() | ||
| 410 | val = val.strip() | ||
| 411 | val = re.sub(r' *\n +', ' ', val) # clean out line continuation | ||
| 412 | self[key] = val | ||
| 413 | |||
| 414 | basename = os.path.basename(fname) | ||
| 415 | |||
| 416 | fpatt = r'(.+?)(?:\.([0-9]+-[0-9]+-[0-9]+)(?:\.(.*))?)?%s' % MKD_PATT | ||
| 417 | title, date, post = re.match(fpatt, basename).groups() | ||
| 418 | title = title.replace("_", " ") | ||
| 419 | post = post and post.replace("_", " ") or None | ||
| 420 | self["title"] = self.get("title", title) | ||
| 421 | if date and "date" not in self: self["date"] = date | ||
| 422 | if post and "post" not in self: self["post"] = post | ||
| 423 | |||
| 424 | self.html = "" | ||
| 425 | |||
| 426 | def __getattr__(self, name): | ||
| 427 | """Attribute-style access to dictionary items.""" | ||
| 428 | try: | ||
| 429 | return self[name] | ||
| 430 | except KeyError: | ||
| 431 | raise AttributeError(name) | ||
| 432 | |||
| 433 | def __str__(self): | ||
| 434 | """Page representation by file name.""" | ||
| 435 | return ('%s (virtual)' % self.fname) if self._virtual else self.fname | ||
| 436 | |||
| 437 | # ----------------------------------------------------------------------------- | ||
| 438 | |||
| 439 | def build(project, opts): | ||
| 440 | """Build a site project.""" | ||
| 441 | |||
| 442 | # ------------------------------------------------------------------------- | ||
| 443 | # utilities | ||
| 444 | # ------------------------------------------------------------------------- | ||
| 445 | |||
| 446 | def abort_iex(page, itype, inline, exc): | ||
| 447 | """Abort because of an exception in inlined Python code.""" | ||
| 448 | print("abort : Python %s in %s failed" % (itype, page)) | ||
| 449 | print((" %s raising the exception " % itype).center(79, "-")) | ||
| 450 | print(inline) | ||
| 451 | print(" exception ".center(79, "-")) | ||
| 452 | print(exc) | ||
| 453 | sys.exit(1) | ||
| 454 | |||
| 455 | # ------------------------------------------------------------------------- | ||
| 456 | # regex patterns and replacements | ||
| 457 | # ------------------------------------------------------------------------- | ||
| 458 | |||
| 459 | regx_escp = re.compile(r'\\((?:(?:<|<)!--|{)(?:{|%))') # escaped code | ||
| 460 | repl_escp = r'\1' | ||
| 461 | regx_rurl = re.compile(r'(?<=(?:(?:\n| )src|href)=")([^#/&%].*?)(?=")') | ||
| 462 | repl_rurl = lambda m: urlparse.urljoin(opts.base_url, m.group(1)) | ||
| 463 | |||
| 464 | regx_eval = re.compile(r'(?<!\\)(?:(?:<!--|{){)(.*?)(?:}(?:-->|}))', re.S) | ||
| 465 | |||
| 466 | def repl_eval(m): | ||
| 467 | """Replace a Python expression block by its evaluation.""" | ||
| 468 | |||
| 469 | expr = m.group(1) | ||
| 470 | try: | ||
| 471 | repl = eval(expr, macros.copy()) | ||
| 472 | except: | ||
| 473 | abort_iex(page, "expression", expr, traceback.format_exc()) | ||
| 474 | else: | ||
| 475 | if not isinstance(repl, basestring): # e.g. numbers | ||
| 476 | repl = unicode(repl) | ||
| 477 | elif not isinstance(repl, unicode): | ||
| 478 | repl = repl.decode("utf-8") | ||
| 479 | return repl | ||
| 480 | |||
| 481 | regx_exec = re.compile(r'(?<!\\)(?:(?:<!--|{)%)(.*?)(?:%(?:-->|}))', re.S) | ||
| 482 | |||
| 483 | def repl_exec(m): | ||
| 484 | """Replace a block of Python statements by their standard output.""" | ||
| 485 | |||
| 486 | stmt = m.group(1).replace("\r\n", "\n") | ||
| 487 | |||
| 488 | # base indentation | ||
| 489 | ind_lvl = len(re.findall(r'^(?: *\n)*( *)', stmt, re.MULTILINE)[0]) | ||
| 490 | ind_rex = re.compile(r'^ {0,%d}' % ind_lvl, re.MULTILINE) | ||
| 491 | stmt = ind_rex.sub('', stmt) | ||
| 492 | |||
| 493 | # execute | ||
| 494 | sys.stdout = StringIO.StringIO() | ||
| 495 | try: | ||
| 496 | exec stmt in macros.copy() | ||
| 497 | except: | ||
| 498 | sys.stdout = sys.__stdout__ | ||
| 499 | abort_iex(page, "statements", stmt, traceback.format_exc()) | ||
| 500 | else: | ||
| 501 | repl = sys.stdout.getvalue()[:-1] # remove last line break | ||
| 502 | sys.stdout = sys.__stdout__ | ||
| 503 | if not isinstance(repl, unicode): | ||
| 504 | repl = repl.decode(opts.input_enc) | ||
| 505 | return repl | ||
| 506 | |||
| 507 | # ------------------------------------------------------------------------- | ||
| 508 | # preparations | ||
| 509 | # ------------------------------------------------------------------------- | ||
| 510 | |||
| 511 | dir_in = opj(project, "input") | ||
| 512 | dir_out = opj(project, "output") | ||
| 513 | page_html = opj(project, "page.html") | ||
| 514 | |||
| 515 | # check required files and folders | ||
| 516 | for pelem in (page_html, dir_in, dir_out): | ||
| 517 | if not opx(pelem): | ||
| 518 | print("abort : %s does not exist, looks like project has not been " | ||
| 519 | "initialized" % pelem) | ||
| 520 | sys.exit(1) | ||
| 521 | |||
| 522 | # prepare output directory | ||
| 523 | for fod in glob.glob(opj(dir_out, "*")): | ||
| 524 | if os.path.isdir(fod): | ||
| 525 | shutil.rmtree(fod) | ||
| 526 | else: | ||
| 527 | os.remove(fod) | ||
| 528 | if not opx(dir_out): | ||
| 529 | os.mkdir(dir_out) | ||
| 530 | |||
| 531 | # macro module | ||
| 532 | fname = opj(opts.project, "macros.py") | ||
| 533 | macros = imp.load_source("macros", fname).__dict__ if opx(fname) else {} | ||
| 534 | |||
| 535 | macros["__encoding__"] = opts.output_enc | ||
| 536 | macros["options"] = opts | ||
| 537 | macros["project"] = project | ||
| 538 | macros["input"] = dir_in | ||
| 539 | macros["output"] = dir_out | ||
| 540 | |||
| 541 | # "builtin" items for use in macros and templates | ||
| 542 | macros["hx"] = hx | ||
| 543 | macros["htmlspecialchars"] = hx # legacy name of `htmlx` function | ||
| 544 | macros["Page"] = Page | ||
| 545 | |||
| 546 | # ------------------------------------------------------------------------- | ||
| 547 | # process input files | ||
| 548 | # ------------------------------------------------------------------------- | ||
| 549 | |||
| 550 | Page._template = macros.get("page", {}) | ||
| 551 | Page._opts = opts | ||
| 552 | Page._pstrip = dir_in | ||
| 553 | pages = [] | ||
| 554 | custom_converter = macros.get('converter', {}) | ||
| 555 | |||
| 556 | for cwd, dirs, files in os.walk(dir_in.decode(opts.filename_enc)): | ||
| 557 | cwd_site = cwd[len(dir_in):].lstrip(os.path.sep) | ||
| 558 | for sdir in dirs[:]: | ||
| 559 | if re.search(opts.ignore, opj(cwd_site, sdir)): | ||
| 560 | dirs.remove(sdir) | ||
| 561 | else: | ||
| 562 | os.mkdir(opj(dir_out, cwd_site, sdir)) | ||
| 563 | for f in files: | ||
| 564 | if re.search(opts.ignore, opj(cwd_site, f)): | ||
| 565 | pass | ||
| 566 | elif re.search(MKD_PATT, f): | ||
| 567 | page = Page(opj(cwd, f)) | ||
| 568 | pages.append(page) | ||
| 569 | else: | ||
| 570 | # either use a custom converter or do a plain copy | ||
| 571 | for patt, (func, ext) in custom_converter.items(): | ||
| 572 | if re.search(patt, f): | ||
| 573 | f_src = opj(cwd, f) | ||
| 574 | f_dst = opj(dir_out, cwd_site, f) | ||
| 575 | f_dst = '%s.%s' % (os.path.splitext(f_dst)[0], ext) | ||
| 576 | print('info : convert %s (%s)' % (f_src, func.__name__)) | ||
| 577 | func(f_src, f_dst) | ||
| 578 | break | ||
| 579 | else: | ||
| 580 | src = opj(cwd, f) | ||
| 581 | try: | ||
| 582 | shutil.copy(src, opj(dir_out, cwd_site)) | ||
| 583 | except OSError: | ||
| 584 | # some filesystems like FAT won't allow shutil.copy | ||
| 585 | shutil.copyfile(src, opj(dir_out, cwd_site, f)) | ||
| 586 | |||
| 587 | pages.sort(key=lambda p: int(p.get("sval", "0"))) | ||
| 588 | |||
| 589 | macros["pages"] = pages | ||
| 590 | |||
| 591 | # ------------------------------------------------------------------------- | ||
| 592 | # run pre-convert hooks in macro module (named 'once' before) | ||
| 593 | # ------------------------------------------------------------------------- | ||
| 594 | |||
| 595 | hooks = [a for a in macros if re.match(r'hook_preconvert_|once_', a)] | ||
| 596 | for fn in sorted(hooks): | ||
| 597 | macros[fn]() | ||
| 598 | |||
| 599 | # ------------------------------------------------------------------------- | ||
| 600 | # convert pages (markdown to HTML) | ||
| 601 | # ------------------------------------------------------------------------- | ||
| 602 | |||
| 603 | for page in pages: | ||
| 604 | |||
| 605 | print("info : convert %s" % page) | ||
| 606 | |||
| 607 | # replace expressions and statements in page source | ||
| 608 | macros["page"] = page | ||
| 609 | out = regx_eval.sub(repl_eval, page.source) | ||
| 610 | out = regx_exec.sub(repl_exec, out) | ||
| 611 | |||
| 612 | # convert to HTML | ||
| 613 | page.html = markdown.Markdown(extensions=opts.md_ext).convert(out) | ||
| 614 | |||
| 615 | # ------------------------------------------------------------------------- | ||
| 616 | # run post-convert hooks in macro module | ||
| 617 | # ------------------------------------------------------------------------- | ||
| 618 | |||
| 619 | hooks = [a for a in macros if a.startswith("hook_postconvert_")] | ||
| 620 | for fn in sorted(hooks): | ||
| 621 | macros[fn]() | ||
| 622 | |||
| 623 | # ------------------------------------------------------------------------- | ||
| 624 | # render complete HTML pages | ||
| 625 | # ------------------------------------------------------------------------- | ||
| 626 | |||
| 627 | with codecs.open(opj(project, "page.html"), 'r', opts.input_enc) as fp: | ||
| 628 | skeleton = fp.read() | ||
| 629 | |||
| 630 | for page in pages: | ||
| 631 | |||
| 632 | print("info : render %s" % page.url) | ||
| 633 | |||
| 634 | # replace expressions and statements in page.html | ||
| 635 | macros["page"] = page | ||
| 636 | macros["__content__"] = page.html | ||
| 637 | out = regx_eval.sub(repl_eval, skeleton) | ||
| 638 | out = regx_exec.sub(repl_exec, out) | ||
| 639 | |||
| 640 | # un-escape escaped python code blocks | ||
| 641 | out = regx_escp.sub(repl_escp, out) | ||
| 642 | |||
| 643 | # make relative links absolute | ||
| 644 | out = regx_rurl.sub(repl_rurl, out) | ||
| 645 | |||
| 646 | # write HTML page | ||
| 647 | fname = page.fname.replace(dir_in, dir_out) | ||
| 648 | fname = re.sub(MKD_PATT, ".html", fname) | ||
| 649 | with codecs.open(fname, 'w', opts.output_enc) as fp: | ||
| 650 | fp.write(out) | ||
| 651 | |||
| 652 | print("success: built project") | ||
| 653 | |||
| 654 | # ============================================================================= | ||
| 655 | # serve site | ||
| 656 | # ============================================================================= | ||
| 657 | |||
| 658 | def serve(project, port): | ||
| 659 | """Temporary serve a site project.""" | ||
| 660 | |||
| 661 | root = opj(project, "output") | ||
| 662 | if not os.listdir(project): | ||
| 663 | print("abort : output dir is empty (build project first!)") | ||
| 664 | sys.exit(1) | ||
| 665 | |||
| 666 | os.chdir(root) | ||
| 667 | server = HTTPServer(('', port), SimpleHTTPRequestHandler) | ||
| 668 | server.serve_forever() | ||
| 669 | |||
| 670 | # ============================================================================= | ||
| 671 | # options | ||
| 672 | # ============================================================================= | ||
| 673 | |||
| 674 | def options(): | ||
| 675 | """Parse and validate command line arguments.""" | ||
| 676 | |||
| 677 | usage = ("Usage: %prog --init [OPTIONS] [path/to/project]\n" | ||
| 678 | " %prog --build [OPTIONS] [path/to/project]\n" | ||
| 679 | " %prog --serve [OPTIONS] [path/to/project]\n" | ||
| 680 | "\n" | ||
| 681 | " Project path is optional, '.' is used as default.") | ||
| 682 | |||
| 683 | op = optparse.OptionParser(usage=usage) | ||
| 684 | |||
| 685 | op.add_option("-i" , "--init", action="store_true", default=False, | ||
| 686 | help="init project") | ||
| 687 | op.add_option("-b" , "--build", action="store_true", default=False, | ||
| 688 | help="build project") | ||
| 689 | op.add_option("-s" , "--serve", action="store_true", default=False, | ||
| 690 | help="serve project") | ||
| 691 | |||
| 692 | og = optparse.OptionGroup(op, "Init options") | ||
| 693 | og.add_option("", "--theme", type="choice", default="minimal", | ||
| 694 | choices=THEME_NAMES, | ||
| 695 | help="theme for a new project (choices: %s)" % ', '.join(THEME_NAMES)) | ||
| 696 | op.add_option_group(og) | ||
| 697 | |||
| 698 | og = optparse.OptionGroup(op, "Build options") | ||
| 699 | og.add_option("", "--base-url", default="/", metavar="URL", | ||
| 700 | help="base url for relative links (default: /)") | ||
| 701 | og.add_option("" , "--ignore", default=r"^\.|~$", metavar="REGEX", | ||
| 702 | help="input files to ignore (default: '^\.|~$')") | ||
| 703 | og.add_option("" , "--md-ext", default=[], metavar="EXT", | ||
| 704 | action="append", help="enable a markdown extension") | ||
| 705 | og.add_option("", "--input-enc", default="utf-8", metavar="ENC", | ||
| 706 | help="encoding of input pages (default: utf-8)") | ||
| 707 | og.add_option("", "--output-enc", default="utf-8", metavar="ENC", | ||
| 708 | help="encoding of output pages (default: utf-8)") | ||
| 709 | og.add_option("", "--filename-enc", default="utf-8", metavar="ENC", | ||
| 710 | help="encoding of file names (default: utf-8)") | ||
| 711 | op.add_option_group(og) | ||
| 712 | |||
| 713 | og = optparse.OptionGroup(op, "Serve options") | ||
| 714 | og.add_option("" , "--port", default=8080, | ||
| 715 | metavar="PORT", type="int", | ||
| 716 | help="port for serving (default: 8080)") | ||
| 717 | op.add_option_group(og) | ||
| 718 | |||
| 719 | opts, args = op.parse_args() | ||
| 720 | |||
| 721 | if opts.init + opts.build + opts.serve < 1: | ||
| 722 | op.print_help() | ||
| 723 | op.exit() | ||
| 724 | |||
| 725 | opts.project = args and args[0] or "." | ||
| 726 | |||
| 727 | return opts | ||
| 728 | |||
| 729 | # ============================================================================= | ||
| 730 | # main | ||
| 731 | # ============================================================================= | ||
| 732 | |||
| 733 | def main(): | ||
| 734 | |||
| 735 | opts = options() | ||
| 736 | |||
| 737 | if opts.init: | ||
| 738 | init(opts.project, opts.theme) | ||
| 739 | if opts.build: | ||
| 740 | build(opts.project, opts) | ||
| 741 | if opts.serve: | ||
| 742 | serve(opts.project, opts.port) | ||
| 743 | |||
| 744 | if __name__ == '__main__': | ||
| 745 | |||
| 746 | main() | ||
