├── requirements.txt ├── .gitignore ├── rux ├── res │ ├── src │ │ └── 2013-08-16-00-18.md │ └── config.toml ├── __init__.py ├── libparser.py ├── config.py ├── renderer.py ├── logger.py ├── exceptions.py ├── utils.py ├── models.py ├── parser.py ├── cli.py ├── pdf.py ├── generator.py ├── daemon.py └── server.py ├── .gitmodules ├── MANIFEST.in ├── CHANGES ├── LICENSE-BSD ├── setup.py ├── src └── libparser.c └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | Jinja2>=2.6 2 | Pygments>=1.6 3 | docopt>=0.6.1 4 | houdini.py>=0.1.0 5 | misaka>=1.0.2 6 | toml.py>=0.1.2 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | .*.swo 3 | *.pyc 4 | *.pyo 5 | venv 6 | .sass-cache 7 | test* 8 | build 9 | rux.egg* 10 | dist 11 | docs/_build 12 | *.so 13 | reinstall.sh 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /rux/res/src/2013-08-16-00-18.md: -------------------------------------------------------------------------------- 1 | New Journey with Rux! 2 | http://vlove.qiniudn.com/2013-09-28-21-15_0.jpg 3 | --- 4 | 5 | Welcome to Rux, this is a sample post. Edit or delete it, then start blogging! 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs/_themes/rux"] 2 | path = docs/_themes/rux 3 | url = https://github.com/hit9/sphinx-theme-rux 4 | [submodule "docs/_themes/plain"] 5 | path = docs/_themes/plain 6 | url = git://github.com/hit9/sphinx-theme-plain.git 7 | [submodule "rux/res/clr"] 8 | path = rux/res/clr 9 | url = git://github.com/hit9/rux-theme-clr.git 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE-BSD README.md requirements.txt src/libparser.c 2 | recursive-include rux/res * 3 | recursive-exclude rux .*.swp 4 | recursive-exclude rux .*.swo 5 | recursive-exclude venv * 6 | recursive-exclude dist * 7 | recursive-exclude build * 8 | recursive-exclude rux.egg-info * 9 | recursive-exclude rux/res/clr/.sass-cache * 10 | recursive-exclude rux/res/default/.sass-cache * 11 | -------------------------------------------------------------------------------- /rux/__init__.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | # 3 | # /(__M__)\ 4 | # /, , , ,\ 5 | #/' ' 'V' ' '\ 6 | # ~ ~ ~ 7 | # rux 8 | # 9 | 10 | """ 11 | Rux 12 | ~~~ 13 | 14 | Micro & Fast static blog generator (markdown => html). 15 | 16 | :author: Chao Wang (Hit9) 17 | :license: BSD 18 | """ 19 | 20 | __version__ = '0.6.6' 21 | 22 | charset = 'utf8' # utf8 input & output 23 | src_ext = '.md' 24 | out_ext = '.html' 25 | src_dir = 'src' 26 | out_dir = '.' 27 | -------------------------------------------------------------------------------- /rux/res/config.toml: -------------------------------------------------------------------------------- 1 | # the root path your site will be served. e.g. "/mysite", (default: "", no sub_path) 2 | root = "" 3 | 4 | [blog] 5 | name = "Sunshine Every Day" 6 | description = "Never give up, my determination is to chase for success" 7 | theme = "clr" # path to theme 8 | 9 | [author] 10 | name = "Hit9" 11 | email = "nz2324@126.com" 12 | description = "I am a happy boy." 13 | url = "http://hit9.org" # author's index url 14 | 15 | [disqus] 16 | enable = true # enable comment? true or false 17 | shortname = "rux" # shortname from disqus.com 18 | 19 | [theme] 20 | email_public = false 21 | github = "hit9" 22 | twitter = "nz2324" 23 | dribbble = "" 24 | pdf_link = "/out.pdf" 25 | -------------------------------------------------------------------------------- /rux/libparser.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.libparser 5 | ~~~~~~~~~~~~~ 6 | 7 | Parse post source, return title, title-picture, body(markdown). 8 | """ 9 | 10 | import os 11 | from ctypes import * 12 | from distutils.sysconfig import get_python_lib 13 | 14 | 15 | dll_path = os.path.join(get_python_lib(), 'ruxlibparser.so') 16 | 17 | libparser = CDLL(dll_path) 18 | 19 | 20 | class Post(Structure): 21 | _fields_ = ( 22 | ('title', c_void_p), 23 | ('tpic', c_void_p), 24 | ('body', c_char_p), 25 | ('tsz', c_int), 26 | ('tpsz', c_int) 27 | ) 28 | 29 | 30 | post = Post() 31 | 32 | 33 | def parse(src): 34 | """Note: src should be ascii string""" 35 | rt = libparser.parse(byref(post), src) 36 | return ( 37 | rt, 38 | string_at(post.title, post.tsz), 39 | string_at(post.tpic, post.tpsz), 40 | post.body 41 | ) 42 | -------------------------------------------------------------------------------- /rux/config.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.config 5 | ~~~~~~~~~~ 6 | 7 | Configuration manager, rux's configuration is in toml. 8 | """ 9 | 10 | from os.path import exists 11 | 12 | from . import charset 13 | from .exceptions import ConfigSyntaxError 14 | from .utils import join 15 | 16 | import toml 17 | 18 | 19 | class Config(object): 20 | 21 | filename = 'config.toml' 22 | filepath = join('.', filename) 23 | 24 | # default configuration 25 | default = { 26 | 'root': '', 27 | 'blog': { 28 | 'name': '', 29 | 'description': '', 30 | 'theme': 'clr', 31 | }, 32 | 'author': { 33 | 'name': 'hit9', 34 | 'email': 'nz2324@126.com', 35 | 'description': 'Who are you?' 36 | }, 37 | 'disqus': { 38 | 'enable': True, 39 | 'shortname': 'rux' 40 | }, 41 | } 42 | 43 | def parse(self): 44 | """parse config, return a dict""" 45 | 46 | if exists(self.filepath): 47 | content = open(self.filepath).read().decode(charset) 48 | else: 49 | content = "" 50 | 51 | try: 52 | config = toml.loads(content) 53 | except toml.TomlSyntaxError: 54 | raise ConfigSyntaxError 55 | 56 | return config 57 | 58 | 59 | config = Config() 60 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | v0.6.6 2 | ------ 3 | 4 | - local http server with translate_path (root path supported) 5 | 6 | v0.6.5 7 | ------ 8 | 9 | - beta version released 10 | 11 | v0.6.3 12 | ------ 13 | 14 | - use `clr` instead of `default` as default theme 15 | 16 | v0.6.2 17 | ------ 18 | 19 | - add option `root` in config.toml 20 | 21 | v0.6.1 22 | ------ 23 | 24 | - minor typo fixes 25 | 26 | v0.5.9 27 | ------ 28 | 29 | - Remove command `rux restart` 30 | - Add optional `` to command: `rux start`, `rux serve` 31 | 32 | v0.5.8 33 | ------ 34 | 35 | - PDF command campatible with OSX 36 | 37 | v0.5.7 38 | ------ 39 | 40 | - Docs & README fixing 41 | 42 | v0.5.6 43 | ------ 44 | 45 | - Add ability to generate PDF 46 | 47 | v0.5.5 48 | ------ 49 | 50 | - use **SINGLE** process to build blog. 51 | 52 | v0.5.3 53 | ------ 54 | 55 | - use C shared library to parse out title, title picture and body 56 | 57 | v0.5.1 58 | ------- 59 | 60 | - Update theme submodule 61 | - some typo fixed 62 | 63 | v0.5.0(**NOT Backward Compatible**) 64 | ----------------------------------- 65 | 66 | - New post syntax 67 | - Add title_pic to post model 68 | - New theme rux-default 69 | 70 | 71 | v0.4.0 72 | ------ 73 | 74 | - rewrite `generator.py`, use mutiple processes to build 75 | site. 76 | 77 | - some typo fix 78 | 79 | - remove blinker dependece, no signals working in building progress. 80 | -------------------------------------------------------------------------------- /LICENSE-BSD: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, hit9 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 15 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 16 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 17 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER 18 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 19 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 20 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 21 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 22 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 23 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /rux/renderer.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.renderer 5 | ~~~~~~~~~~~~ 6 | 7 | Render data to html with jinja2 templates. 8 | """ 9 | 10 | from . import charset 11 | from .exceptions import JinjaTemplateNotFound 12 | 13 | from jinja2 import Environment, FileSystemLoader 14 | from jinja2.exceptions import TemplateNotFound 15 | 16 | 17 | class Renderer(object): 18 | 19 | def initialize(self, templates_path, global_data): 20 | """initialize with templates' path 21 | parameters 22 | templates_path str the position of templates directory 23 | global_data dict globa data can be got in any templates""" 24 | self.env = Environment(loader=FileSystemLoader(templates_path)) 25 | self.env.trim_blocks = True 26 | self.global_data = global_data 27 | 28 | def render(self, template, **data): 29 | """Render data with template, return html unicodes. 30 | parameters 31 | template str the template's filename 32 | data dict the data to render 33 | """ 34 | # make a copy and update the copy 35 | dct = self.global_data.copy() 36 | dct.update(data) 37 | 38 | try: 39 | html = self.env.get_template(template).render(**dct) 40 | except TemplateNotFound: 41 | raise JinjaTemplateNotFound 42 | return html 43 | 44 | def render_to(self, path, template, **data): 45 | """Render data with template and then write to path""" 46 | html = self.render(template, **data) 47 | with open(path, 'w') as f: 48 | f.write(html.encode(charset)) 49 | 50 | 51 | renderer = Renderer() # initialized a renderer, and use it each time 52 | -------------------------------------------------------------------------------- /rux/logger.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.logger 5 | ~~~~~~~~~~ 6 | 7 | rux's colorful logger. 8 | """ 9 | 10 | from datetime import datetime 11 | import logging 12 | from logging import Formatter 13 | from logging import getLogger 14 | from logging import StreamHandler 15 | import sys 16 | 17 | from utils import colored 18 | 19 | 20 | class ColoredFormatter(Formatter): 21 | """colored text output formatter""" 22 | 23 | def format(self, record): 24 | message = record.getMessage() 25 | mapping = { 26 | 'CRITICAL': 'bgred', 27 | 'ERROR': 'red', 28 | 'WARNING': 'yellow', 29 | 'SUCCESS': 'green', 30 | 'INFO': 'cyan', 31 | 'DEBUG': 'bggrey', 32 | } 33 | color = mapping.get(record.levelname, 'white') 34 | level = colored('%-8s' % record.levelname, color) 35 | time = colored(datetime.now().strftime("(%H:%M:%S)"), 'magenta') 36 | return " ".join([level, time, message]) 37 | 38 | 39 | logger = getLogger('rux') 40 | logging.SUCCESS = 25 # WARNING(30) > SUCCESS(25) > INFO(20) 41 | logging.addLevelName(logging.SUCCESS, 'SUCCESS') 42 | logger.success = lambda msg, *args, **kwargs: logger.log(logging.SUCCESS, msg, *args, **kwargs) 43 | 44 | # add colored handler 45 | handler = StreamHandler(sys.stdout) 46 | formatter = ColoredFormatter() 47 | handler.setFormatter(formatter) 48 | logger.addHandler(handler) 49 | 50 | 51 | if __name__ == '__main__': 52 | """Test all levels out""" 53 | logger.setLevel(logging.DEBUG) 54 | logger.info('info') 55 | logger.success('success') 56 | logger.debug('debug') 57 | logger.warning('warning') 58 | logger.error('error') 59 | logger.critical('critical') 60 | -------------------------------------------------------------------------------- /rux/exceptions.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.exceptions 5 | ~~~~~~~~~~~~~~ 6 | 7 | All possible exceptions. 8 | """ 9 | 10 | 11 | class RuxException(Exception): 12 | """There was an ambiguous exception that occurred while handling 13 | rux's process""" 14 | pass 15 | 16 | 17 | # !Fatal 18 | 19 | class RuxFatalError(RuxException): 20 | """There was a fatal error exception that occurred in rux process""" 21 | exit_code = 1 # must terminate its process with a non-zero exit code 22 | pass 23 | 24 | 25 | class SourceDirectoryNotFound(RuxFatalError): 26 | """Source directory was not found""" 27 | 28 | exit_code = 2 29 | pass 30 | 31 | 32 | class ConfigSyntaxError(RuxFatalError): 33 | """Toml syntax error occurred in config.toml""" 34 | exit_code = 3 35 | pass 36 | 37 | 38 | class JinjaTemplateNotFound(RuxFatalError): 39 | """Jinja2 template was not found""" 40 | exit_code = 4 41 | pass 42 | 43 | 44 | # Warning 45 | 46 | class RuxWarnException(RuxException): # warning exception 47 | """There was a warning exception that occurred in rux process""" 48 | pass 49 | 50 | 51 | class ParseException(RuxWarnException): 52 | """There was an exception while parsing the source""" 53 | pass 54 | 55 | 56 | class RenderException(RuxWarnException): 57 | """There was an exception while rendering to html""" 58 | pass 59 | 60 | 61 | class PostNameInvalid(ParseException): 62 | """Invalid post name, should be datetime, like '1992-04-05-10-10'""" 63 | # 1992-04-05 is my birthday! :) 64 | pass 65 | 66 | 67 | class SeparatorNotFound(ParseException): 68 | """Separator '---' not found in post source""" 69 | pass 70 | 71 | 72 | class PostTitleNotFound(ParseException): 73 | """There was no title found in post's source""" 74 | pass 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | Rux 5 | --- 6 | 7 | Micro & Fast static blog generator (markdown => html). 4 - Beta 8 | 9 | Features 10 | ```````` 11 | 12 | * Static: Markdown => HTML 13 | * Not tags, No categories, No feed generation, No ... 14 | * Minimal & Simple configuration 15 | * Ability to run in the background as a daemon 16 | * Ability to save posts in PDF for offline reading 17 | * Ability to build automatically once source updated 18 | 19 | Installation 20 | ````````````` 21 | 22 | .. code:: bash 23 | 24 | $ mkdir MyBlog 25 | $ cd MyBlog 26 | $ virtualenv venv 27 | New python executable in venv/bin/python 28 | Installing setuptools............done. 29 | Installing pip...............done. 30 | $ . venv/bin/activate 31 | $ pip install rux 32 | 33 | Links 34 | ````` 35 | 36 | * GitHub 37 | """ 38 | 39 | from setuptools import setup, Extension 40 | 41 | 42 | setup( 43 | name='rux', 44 | version='0.6.6', 45 | author='hit9', 46 | author_email='nz2324@126.com', 47 | description='''Micro & Fast static blog generator based on markdown''', 48 | license='BSD', 49 | keywords='static blog generator markdown, html', 50 | url='http://github.com/hit9/rux', 51 | packages=['rux'], 52 | include_package_data=True, 53 | zip_safe=False, 54 | entry_points={ 55 | 'console_scripts': [ 56 | 'rux=rux.cli:main' 57 | ] 58 | }, 59 | install_requires=open("requirements.txt").read().splitlines(), 60 | dependency_links=[ 61 | 'https://github.com/hit9/toml.py/zipball/master#egg=toml.py-0.1.2', 62 | ], 63 | ext_modules=[Extension('ruxlibparser', ['src/libparser.c'])], 64 | long_description=__doc__, 65 | classifiers=[ 66 | 'Development Status :: 4 - Beta', 67 | 'Environment :: Console', 68 | 'Intended Audience :: Customer Service', 69 | 'License :: OSI Approved :: BSD License', 70 | 'Operating System :: POSIX', 71 | 'Programming Language :: Python', 72 | ] 73 | ) 74 | -------------------------------------------------------------------------------- /rux/utils.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.utils 5 | ~~~~~~~~~ 6 | 7 | All helper functions defined here. 8 | """ 9 | 10 | import os 11 | import errno 12 | 13 | 14 | class Color(object): 15 | """ 16 | utility to return colored ansi text. 17 | usage:: 18 | 19 | >>> colored("text", "red") 20 | '\x1b[31mtext\x1b[0m']]' 21 | 22 | """ 23 | 24 | colors = { 25 | 'black': 30, 26 | 'red': 31, 27 | 'green': 32, 28 | 'yellow': 33, 29 | 'blue': 34, 30 | 'magenta': 35, 31 | 'cyan': 36, 32 | 'white': 37, 33 | 'bgred': 41, 34 | 'bggrey': 100 35 | } 36 | 37 | prefix = '\033[' 38 | suffix = '\033[0m' 39 | 40 | def colored(self, text, color=None): 41 | 42 | if color not in self.colors: 43 | color = 'white' 44 | 45 | clr = self.colors[color] 46 | return (self.prefix + '%dm%s' + self.suffix) % (clr, text) 47 | 48 | 49 | colored = Color().colored 50 | 51 | 52 | def join(*p): 53 | """return normpath version of path.join""" 54 | return os.path.normpath(os.path.join(*p)) 55 | 56 | 57 | def chunks(lst, number): 58 | """ 59 | A generator, split list `lst` into `number` equal size parts. 60 | usage:: 61 | 62 | >>> parts = chunks(range(8),3) 63 | >>> parts 64 | 65 | >>> list(parts) 66 | [[0, 1, 2], [3, 4, 5], [6, 7]] 67 | 68 | """ 69 | lst_len = len(lst) 70 | 71 | for i in xrange(0, lst_len, number): 72 | yield lst[i: i+number] 73 | 74 | 75 | def update_nested_dict(a, b): 76 | """ 77 | update nested dict `a` with another dict b. 78 | usage:: 79 | 80 | >>> a = {'x' : { 'y': 1}} 81 | >>> b = {'x' : {'z':2, 'y':3}, 'w': 4} 82 | >>> update_nested_dict(a,b) 83 | {'x': {'y': 3, 'z': 2}, 'w': 4} 84 | 85 | """ 86 | for k, v in b.iteritems(): 87 | if isinstance(v, dict): 88 | d = a.setdefault(k, {}) 89 | update_nested_dict(d, v) 90 | else: 91 | a[k] = v 92 | return a 93 | 94 | 95 | def mkdir_p(path): 96 | """mkdir -p 97 | Note: comes from stackoverflow""" 98 | try: 99 | os.makedirs(path) 100 | except OSError as exc: # Python >2.5 101 | if exc.errno == errno.EEXIST and os.path.isdir(path): 102 | pass 103 | else: 104 | raise 105 | -------------------------------------------------------------------------------- /src/libparser.c: -------------------------------------------------------------------------------- 1 | /* 2 | * rux.libparser 3 | * ~~~~~~~~~~~~~ 4 | * 5 | * Parse post source, set the struct instance's data: 6 | * 7 | * title, titlc picture, body 8 | * 9 | * If error, return negative int, else return 0; 10 | */ 11 | 12 | 13 | typedef struct post { 14 | char *title; /* title */ 15 | char *tpic; /* title picture */ 16 | char *body; /* markdown body */ 17 | int tsz; /* title size */ 18 | int tpsz; /* title picture size */ 19 | } post_t; 20 | 21 | 22 | /* 23 | * return: 24 | * 0 ok 25 | * -1 separator not found 26 | * -2 title not found 27 | */ 28 | int 29 | parse(post_t *t, char *src) 30 | { 31 | /* 32 | * find separator 33 | */ 34 | char *p = src, *e = src; 35 | int separator_found = 0; //was the separator found? 36 | 37 | while (*p != '\0') { 38 | while (*p == ' ' || *p == '\t') p++; //skip spaces 39 | 40 | if (*p == '-' && *(p+1) == '-' && *(p+2) == '-') { // meet a '---' 41 | while (*p == '-') p++; // skip the rest '-' 42 | while (*p == ' ' || *p == '\t') p++; // skip spaces 43 | if (*p == '\n' || *p == '\0') { 44 | separator_found = 1; // separator is this line, p is the last char of this line 45 | break; 46 | } 47 | } 48 | 49 | // current line is illegal, go to next line 50 | for (; *p != '\0' && *p != '\n'; p++); 51 | e = p; 52 | p++; 53 | } 54 | 55 | if (!separator_found) return -1; 56 | 57 | //skip empty 58 | while (*p == '\n' || *p == '\t' || *p == ' ') p++; 59 | 60 | t->body = p; // got the body 61 | 62 | /* 63 | * find title 64 | */ 65 | char *q = src, *x, *y; 66 | 67 | while (q < e && (*q == ' ' || *q == '\t' || *q == '\n')) q++; 68 | 69 | if (q != e) t->title = x = q; // the first non-space char 70 | else 71 | return -2; // no title found 72 | 73 | while (q < e && *q != '\n') q++; //seek to line end 74 | 75 | // find the last non-space char in this line 76 | for (y=q-1; y > x && (*y == ' ' || *y == '\t'); y--); 77 | 78 | t->tsz = (int)(y - x + 1); 79 | 80 | /* 81 | * find title picture 82 | */ 83 | while (q < e && (*q == ' ' || *q == '\t' || *q == '\n')) q++; 84 | 85 | if (q != e) t->tpic = x = q; 86 | else { // No title picture found, return 87 | t->tpic = q; 88 | t->tpsz = 0; 89 | return 0; 90 | } 91 | 92 | while (q < e && *q != '\n') q++; //seek to line end 93 | 94 | // find the last non-space char in this line 95 | for (y=q-1; y > x && (*y == ' ' || *y == '\t'); y--); 96 | 97 | t->tpsz = (int)(y - x + 1); 98 | return 0; 99 | } 100 | -------------------------------------------------------------------------------- /rux/models.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.models 5 | ~~~~~~~~~~ 6 | 7 | rux's models: blog, author, post, page 8 | """ 9 | 10 | from . import src_ext, out_ext, src_dir, out_dir 11 | from .utils import join 12 | from hashlib import md5 13 | 14 | 15 | class Blog(object): 16 | """The blog 17 | attributes 18 | name unicode blog's name 19 | description unicode blog's description 20 | theme str blog's theme""" 21 | 22 | def __init__(self, name="", description="", theme=""): 23 | self.name = name 24 | self.description = description 25 | self.theme = theme 26 | 27 | 28 | blog = Blog() 29 | 30 | 31 | class Author(object): 32 | """The blog's owner, only one 33 | attributes 34 | name unicode author's name 35 | email unicode author's email 36 | """ 37 | 38 | def __init__(self, name="", email=""): 39 | self.name = name 40 | self.email = email 41 | 42 | @property 43 | def gravatar_id(self): 44 | """it's md5(author.email), author's gravatar_id""" 45 | return md5(self.email).hexdigest() 46 | 47 | 48 | author = Author() 49 | 50 | 51 | class Post(object): 52 | """The blog's post object. 53 | attributes 54 | name unicode post's filename without extension 55 | title unicode post's title 56 | datetime datetime post's created time 57 | markdown unicode post's body source, it's in markdown 58 | html unicode post's html, parsed from markdown 59 | summary unicode post's summary 60 | filepath unicode post's filepath 61 | title_pic unicode post's title picture""" 62 | 63 | src_dir = src_dir 64 | out_dir = join(out_dir, "post") 65 | template = "post.html" 66 | 67 | def __init__(self, name="", title="", datetime=None, markdown="", 68 | html="", summary="", filepath="", title_pic=""): 69 | self.name = name 70 | self.title = title 71 | self.datetime = datetime 72 | self.markdown = markdown 73 | self.html = html 74 | self.summary = summary 75 | self.filepath = filepath 76 | self.title_pic = title_pic 77 | 78 | @property 79 | def src(self): 80 | return join(Post.src_dir, self.name + src_ext) 81 | 82 | @property 83 | def out(self): 84 | return join(Post.out_dir, self.name + out_ext) 85 | 86 | 87 | class Page(object): 88 | """The 1st, 2nd, 3rd page.. 89 | attributes 90 | number int the page's order 91 | posts list lists of post objects 92 | first bool is the first page? 93 | last bool is the last page?""" 94 | 95 | template = "page.html" 96 | out_dir = join(out_dir, "page") 97 | 98 | def __init__(self, number=1, posts=None, first=False, last=False): 99 | self.number = number 100 | self.first = first 101 | self.last = last 102 | 103 | if posts is None: 104 | self.posts = [] 105 | else: 106 | self.posts = posts 107 | 108 | @property 109 | def out(self): 110 | if self.first: 111 | return join(out_dir, "index" + out_ext) 112 | else: 113 | return join(Page.out_dir, str(self.number) + out_ext) 114 | -------------------------------------------------------------------------------- /rux/parser.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.parser 5 | ~~~~~~~~~~ 6 | 7 | Parser from post source to html. 8 | """ 9 | 10 | from datetime import datetime 11 | import os 12 | 13 | from . import charset, src_ext 14 | from .exceptions import * 15 | import libparser 16 | 17 | import houdini 18 | import misaka 19 | from misaka import HtmlRenderer, SmartyPants 20 | from pygments import highlight 21 | from pygments.lexers import get_lexer_by_name 22 | from pygments.formatters import HtmlFormatter 23 | from pygments.util import ClassNotFound 24 | 25 | src_ext_len = len(src_ext) # cache this, call only once 26 | 27 | to_unicode = lambda string: string.decode(charset) 28 | 29 | 30 | class RuxHtmlRenderer(HtmlRenderer, SmartyPants): 31 | """misaka render with color codes feature""" 32 | 33 | def _code_no_lexer(self, text): 34 | # encode to utf8 string 35 | text = text.encode(charset).strip() 36 | return( 37 | """ 38 |
39 |
%s
40 |
41 | """ % houdini.escape_html(text) 42 | ) 43 | 44 | def block_code(self, text, lang): 45 | """text: unicode text to render""" 46 | 47 | if not lang: 48 | return self._code_no_lexer(text) 49 | 50 | try: 51 | lexer = get_lexer_by_name(lang, stripall=True) 52 | except ClassNotFound: # lexer not found, use plain text 53 | return self._code_no_lexer(text) 54 | 55 | formatter = HtmlFormatter() 56 | 57 | return highlight(text, lexer, formatter) 58 | 59 | 60 | class Parser(object): 61 | """Usage:: 62 | 63 | parser = Parser() 64 | parser.parse(str) # return dict 65 | parser.markdown.render(markdown_str) # render markdown to html 66 | 67 | """ 68 | 69 | def __init__(self): 70 | """Initialize the parser, set markdown render handler as 71 | an attribute `markdown` of the parser""" 72 | render = RuxHtmlRenderer() # initialize the color render 73 | extensions = ( 74 | misaka.EXT_FENCED_CODE | 75 | misaka.EXT_NO_INTRA_EMPHASIS | 76 | misaka.EXT_AUTOLINK 77 | ) 78 | 79 | self.markdown = misaka.Markdown(render, extensions=extensions) 80 | 81 | def parse_markdown(self, markdown): 82 | return self.markdown.render(markdown) 83 | 84 | def parse(self, source): 85 | """Parse ascii post source, return dict""" 86 | 87 | rt, title, title_pic, markdown = libparser.parse(source) 88 | 89 | if rt == -1: 90 | raise SeparatorNotFound 91 | elif rt == -2: 92 | raise PostTitleNotFound 93 | 94 | # change to unicode 95 | title, title_pic, markdown = map(to_unicode, (title, title_pic, 96 | markdown)) 97 | 98 | # render to html 99 | html = self.markdown.render(markdown) 100 | summary = self.markdown.render(markdown[:200]) 101 | 102 | return { 103 | 'title': title, 104 | 'markdown': markdown, 105 | 'html': html, 106 | 'summary': summary, 107 | 'title_pic': title_pic 108 | } 109 | 110 | def parse_filename(self, filepath): 111 | """parse post source files name to datetime object""" 112 | name = os.path.basename(filepath)[:-src_ext_len] 113 | try: 114 | dt = datetime.strptime(name, "%Y-%m-%d-%H-%M") 115 | except ValueError: 116 | raise PostNameInvalid 117 | return {'name': name, 'datetime': dt, 'filepath': filepath} 118 | 119 | 120 | parser = Parser() # build a runtime parser 121 | -------------------------------------------------------------------------------- /rux/cli.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.cli 5 | ~~~~~~~ 6 | 7 | rux's commandline interface. 8 | """ 9 | 10 | import sys 11 | import datetime 12 | import logging 13 | from subprocess import call 14 | from os.path import dirname, exists 15 | 16 | from . import __version__ 17 | from . import src_ext 18 | from .daemon import daemon 19 | from .exceptions import SourceDirectoryNotFound 20 | from .generator import generator 21 | from .pdf import pdf_generator 22 | from .logger import logger 23 | from .models import Post 24 | from .server import server 25 | from .utils import join 26 | 27 | from docopt import docopt 28 | 29 | 30 | usage = """Usage: 31 | rux [-h|-v] 32 | rux post 33 | rux (deploy|build|clean) 34 | rux (serve|start) [] 35 | rux (stop|status) 36 | rux pdf 37 | 38 | Options: 39 | -h --help show help 40 | -v --version show version 41 | 42 | Commands: 43 | post create a new post 44 | deploy create new blog in current directory 45 | build build source files to htmls 46 | serve start a HTTP server and watch source changes 47 | clean remove all htmls rux built 48 | start start http server and rebuilder in the background 49 | stop stop http server and rebuilder daemon 50 | status report the daemon's status 51 | restart restart the daemon 52 | pdf generate all posts to PDF""" 53 | 54 | 55 | def deploy_blog(): 56 | """Deploy new blog to current directory""" 57 | logger.info(deploy_blog.__doc__) 58 | # `rsync -aqu path/to/res/* .` 59 | call( 60 | 'rsync -aqu ' + join(dirname(__file__), 'res', '*') + ' .', 61 | shell=True) 62 | logger.success('Done') 63 | logger.info('Please edit config.toml to meet your needs') 64 | 65 | 66 | def new_post(): 67 | """Touch a new post in src/""" 68 | logger.info(new_post.__doc__) 69 | # make the new post's filename 70 | now = datetime.datetime.now() 71 | now_s = now.strftime('%Y-%m-%d-%H-%M') 72 | filepath = join(Post.src_dir, now_s + src_ext) 73 | # check if `src/` exists 74 | if not exists(Post.src_dir): 75 | logger.error(SourceDirectoryNotFound.__doc__) 76 | sys.exit(SourceDirectoryNotFound.exit_code) 77 | # write sample content to new post 78 | content = ( 79 | 'Title\n' 80 | 'Title Picture URL\n' 81 | '---\n' 82 | 'Markdown content ..' 83 | ) 84 | f = open(filepath, 'w') 85 | f.write(content) 86 | f.close() 87 | logger.success('New post created: %s' % filepath) 88 | 89 | 90 | def clean(): 91 | """Clean htmls rux built: `rm -rf post page index.html`""" 92 | logger.info(clean.__doc__) 93 | paths = ['post', 'page', 'index.html'] 94 | call(['rm', '-rf'] + paths) 95 | logger.success('Done') 96 | 97 | 98 | def main(): 99 | arguments = docopt(usage, version=__version__) 100 | logger.setLevel(logging.INFO) # !important 101 | 102 | # valiad port argument 103 | port = arguments[''] or '8888' 104 | 105 | if (not port.isdigit()) or (not 0 < int(port) < 65535): 106 | logger.error('Port must be an integer in 0-65535.') 107 | sys.exit(1) 108 | else: 109 | port = int(port) 110 | 111 | if arguments['post']: 112 | new_post() 113 | elif arguments['deploy']: 114 | deploy_blog() 115 | elif arguments['build']: 116 | generator.generate() 117 | elif arguments["serve"]: 118 | server.run(port) 119 | elif arguments['clean']: 120 | clean() 121 | elif arguments['start']: 122 | daemon.start(port) 123 | elif arguments['stop']: 124 | daemon.stop() 125 | elif arguments['status']: 126 | daemon.status() 127 | elif arguments['pdf']: 128 | pdf_generator.generate() 129 | else: 130 | exit(usage) 131 | 132 | 133 | if __name__ == '__main__': 134 | main() 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Rux](https://raw.githubusercontent.com/hit9/artworks/master/png/Rux.png) 2 | ======================================================================= 3 | 4 | Micro & Fast static blog generator (markdown => html). 5 | 6 | latest version: v0.6.6-Beta 7 | 8 | Features 9 | -------- 10 | 11 | - Static: Markdown => HTML 12 | 13 | - Not tags, No categories, No feed generation, No ... 14 | 15 | - Minimal & Simple configuration 16 | 17 | - Ability to run in the background as a daemon 18 | 19 | - Ability to save posts in PDF for offline reading 20 | 21 | - Ability to build automatically once source updated 22 | 23 | Installation 24 | ------------ 25 | 26 | ```bash 27 | $ mkdir MyBlog 28 | $ cd MyBlog 29 | $ virtualenv venv 30 | New python executable in venv/bin/python 31 | Installing setuptools............done. 32 | Installing pip...............done. 33 | $ . venv/bin/activate 34 | $ pip install rux 35 | ``` 36 | 37 | Install troubles: https://github.com/hit9/rux#common-issues 38 | 39 | Demo 40 | ---- 41 | 42 | - http://hit9.github.io (https://github.com/hit9/hit9.github.io.git) 43 | 44 | 45 | QuickStart 46 | ----------- 47 | 48 | 1. Create a new directory and install rux: 49 | 50 | ```bash 51 | mkdir myblog && cd myblog 52 | virtualenv venv 53 | . venv/bin/activate 54 | pip install rux 55 | ``` 56 | 57 | Deploy blog inside it: 58 | 59 | ```bash 60 | mkdir blog && cd blog 61 | rux deploy 62 | ``` 63 | 64 | 2. Edit generated configuration: 65 | 66 | ```bash 67 | vi config.toml 68 | ``` 69 | 70 | 3. Start rux daemon: 71 | 72 | ```bash 73 | rux start 74 | ``` 75 | 76 | 4. New a post: 77 | 78 | ```bash 79 | rux post 80 | ``` 81 | 82 | 5. Preview site in browser, default url: `0.0.0.0:8888`. 83 | 84 | Sample Post 85 | ------------ 86 | 87 | Sample post: 88 | 89 | ```markdown 90 | Hello World 91 | http://titlepic.jpg 92 | --- 93 | **Hello World** 94 | ``` 95 | 96 | A post is separated into head and body by ``---``. 97 | 98 | Head includes title (required) and title picture (optional), body is in markdown. 99 | 100 | 101 | Commands 102 | -------- 103 | 104 | To deploy a new blog in new-created directory: 105 | 106 | ```bash 107 | rux deploy 108 | ``` 109 | 110 | To build site from source to htmls: 111 | 112 | ```bash 113 | rux build 114 | ``` 115 | 116 | To create a new post: 117 | 118 | ```bash 119 | rux post 120 | ``` 121 | 122 | To remove all htmls rux built: 123 | 124 | ```bash 125 | rux clean 126 | ``` 127 | 128 | To start a HTTP server and watch source changes: 129 | 130 | ```bash 131 | rux serve 132 | ``` 133 | 134 | When you save your writings, rux can detect the changes and start rebuilding. 135 | 136 | To run rux's server and rebuilder in the background(so we can write blog with at most one shell session.): 137 | 138 | ```bash 139 | rux start 140 | ``` 141 | 142 | To generate all posts to pdf: 143 | 144 | ```bash 145 | rux pdf 146 | ``` 147 | 148 | Themes 149 | ------ 150 | 151 | You really should manage your theme in a standalone git repository, and use it as a submodule of your blog's submodule if your blog is under git versioning too. 152 | 153 | For instance, add theme `default` a submodule of your blog's repo: 154 | 155 | ``` 156 | $ git submodule add git://github.com/hit9/rux-theme-default.git default 157 | ``` 158 | If you want to modify a theme created by someone else, just fork his(or her) repo, and then modify it. 159 | 160 | But it's 100% ok to use themes not in the submodule way. 161 | 162 | Theme list: 163 | 164 | - default: https://github.com/hit9/rux-theme-default by @hit9 165 | 166 | - clr: https://github.com/hit9/rux-theme-clr by @hit9 167 | 168 | Common Issues 169 | -------------- 170 | 171 | 1. Installation troubles on Ubuntu: `cann't find Python.h`, solution: 172 | 173 | ``` 174 | sudo apt-get install python-dev 175 | ``` 176 | 177 | 2. How to generate PDF from my blog? You need to install [wkhtmltopdf](http://wkhtmltopdf.org/downloads.html) first: 178 | 179 | License 180 | ------- 181 | 182 | BSD. `Rux` can be used, modified for any purpose. 183 | -------------------------------------------------------------------------------- /rux/pdf.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.pdf 5 | ~~~~~~~ 6 | 7 | Generate PDF using wkhtmltopdf. 8 | """ 9 | 10 | import sys 11 | import time 12 | import subprocess 13 | import os 14 | from os import listdir as ls 15 | from os.path import exists 16 | 17 | from . import src_ext, charset 18 | from .config import config 19 | from .exceptions import * 20 | from .parser import parser 21 | from .renderer import renderer 22 | from .logger import logger 23 | from .models import blog, author, Post 24 | from .utils import update_nested_dict, join 25 | 26 | 27 | def render(template, **data): 28 | """shortcut to render data with `template`. Just add exception 29 | catch to `renderer.render`""" 30 | try: 31 | return renderer.render(template, **data) 32 | except JinjaTemplateNotFound as e: 33 | logger.error(e.__doc__ + ', Template: %r' % template) 34 | sys.exit(e.exit_code) 35 | 36 | 37 | class PDFGenerator(object): 38 | 39 | def __init__(self): 40 | self.commands = ['wkhtmltopdf', '-', 'out.pdf'] 41 | self.config = config.default 42 | self.blog = blog 43 | self.author = author 44 | self.posts = [] 45 | self.html = None 46 | 47 | def initialize(self): 48 | 49 | # read config 50 | try: 51 | conf = config.parse() 52 | except ConfigSyntaxError as e: 53 | logger.error(e.__doc__) 54 | sys.exit(e.exit_code) 55 | 56 | update_nested_dict(self.config, conf) 57 | 58 | self.blog.__dict__.update(self.config['blog']) 59 | self.author.__dict__.update(self.config['author']) 60 | 61 | # initialize jinja2 62 | templates = join(self.blog.theme, 'templates') 63 | # set a renderer 64 | jinja2_global_data = { 65 | 'blog': self.blog, 66 | 'author': self.author, 67 | 'config': self.config 68 | } 69 | renderer.initialize(templates, jinja2_global_data) 70 | 71 | logger.success('Initialized') 72 | 73 | def get_posts(self): 74 | if not exists(Post.src_dir): 75 | logger.error(SourceDirectoryNotFound.__doc__) 76 | sys.exit(SourceDirectoryNotFound.exit_code) 77 | 78 | source_files = [join(Post.src_dir, fn) 79 | for fn in ls(Post.src_dir) if fn.endswith(src_ext)] 80 | 81 | for filepath in source_files: 82 | try: 83 | data = parser.parse_filename(filepath) 84 | except ParseException as e: # skip single post parse exception 85 | logger.warn(e.__doc__ + ', filepath: %r' % filepath) 86 | else: 87 | self.posts.append(Post(**data)) 88 | 89 | # sort posts by its created time, from new to old 90 | self.posts.sort(key=lambda post: post.datetime.timetuple(), 91 | reverse=True) 92 | 93 | def replace_relative_url_to_absolute(self, content): 94 | """Replace '../' leaded url with absolute uri. 95 | """ 96 | p = os.path.join(os.getcwd(), './src', '../') 97 | return content.replace('../', p) 98 | 99 | def parse_posts(self): 100 | 101 | for post in self.posts: 102 | with open(post.filepath, 'r') as file: 103 | content = file.read() 104 | content= self.replace_relative_url_to_absolute(content) 105 | try: 106 | data = parser.parse(content) 107 | except ParseException, e: 108 | logger.warn(e.__doc__ + ', filepath %r' % post.filepath) 109 | pass 110 | else: 111 | post.__dict__.update(data) 112 | logger.success('Posts parsed') 113 | 114 | def render(self): 115 | self.html = render('pdf.html', posts=self.posts, 116 | BLOG_ABS_PATH=unicode(os.getcwd(), "utf8")) 117 | logger.success('Posts rendered') 118 | 119 | def generate(self): 120 | start_time = time.time() 121 | self.initialize() 122 | self.get_posts() 123 | self.parse_posts() 124 | self.render() 125 | 126 | logger.info('Generate pdf with wkhtmltopdf:') 127 | 128 | try: 129 | proc = subprocess.Popen(self.commands, stdin=subprocess.PIPE, 130 | stdout=sys.stdout, stderr=sys.stderr) 131 | except OSError: # wkhtmltopdf not found 132 | logger.error('Try to install wkhtmltopdf first %s' % 133 | 'http://rux.readthedocs.org/en/latest/pdf.html') 134 | sys.exit(1) 135 | stdout, stderr = proc.communicate(input=self.html.encode(charset)) 136 | logger.success('Generated to out.pdf in %.3f seconds' % 137 | (time.time() - start_time)) 138 | 139 | 140 | pdf_generator = PDFGenerator() 141 | -------------------------------------------------------------------------------- /rux/generator.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.generator 5 | ~~~~~~~~~~~~~ 6 | 7 | The core builder processor. 8 | """ 9 | 10 | from datetime import datetime 11 | import gc 12 | from os import listdir as ls 13 | from os.path import exists 14 | import sys 15 | import time 16 | 17 | from . import src_ext 18 | from .config import config 19 | from .exceptions import * 20 | from .logger import logger 21 | from .models import blog, author, Post, Page 22 | from .parser import parser 23 | from .renderer import renderer 24 | from .utils import chunks, update_nested_dict, mkdir_p, join 25 | 26 | 27 | def render_to(path, template, **data): 28 | """shortcut to render data with `template` and then write to `path`. 29 | Just add exception catch to `renderer.render_to`""" 30 | try: 31 | renderer.render_to(path, template, **data) 32 | except JinjaTemplateNotFound as e: 33 | logger.error(e.__doc__ + ', Template: %r' % template) 34 | sys.exit(e.exit_code) 35 | 36 | 37 | class Generator(object): 38 | 39 | POSTS_COUNT_EACH_PAGE = 15 40 | 41 | def __init__(self): 42 | self.reset() 43 | 44 | def reset(self): 45 | self.config = config.default 46 | self.author = author 47 | self.blog = blog 48 | self.posts = [] 49 | self.pages = [] 50 | self.root = '' 51 | 52 | gc.collect() 53 | 54 | def initialize(self): 55 | """Initialize configuration and renderer environment""" 56 | 57 | # read configuration 58 | try: 59 | conf = config.parse() 60 | except ConfigSyntaxError as e: 61 | logger.error(e.__doc__) 62 | sys.exit(e.exit_code) 63 | 64 | # update default configuration with user defined 65 | update_nested_dict(self.config, conf) 66 | self.blog.__dict__.update(self.config['blog']) 67 | self.author.__dict__.update(self.config['author']) 68 | self.root = self.config['root'] 69 | 70 | # initialize jinja2 71 | templates = join(self.blog.theme, 'templates') # templates directory path 72 | # set a renderer 73 | jinja2_global_data = { 74 | 'blog': self.blog, 75 | 'author': self.author, 76 | 'config': self.config, 77 | 'root': self.root 78 | } 79 | renderer.initialize(templates, jinja2_global_data) 80 | logger.success('Initialized') 81 | 82 | def get_posts(self): 83 | 84 | if not exists(Post.src_dir): 85 | logger.error(SourceDirectoryNotFound.__doc__) 86 | sys.exit(SourceDirectoryNotFound.exit_code) 87 | 88 | source_files = [join(Post.src_dir, fn) 89 | for fn in ls(Post.src_dir) if fn.endswith(src_ext)] 90 | 91 | for filepath in source_files: 92 | try: 93 | data = parser.parse_filename(filepath) 94 | except ParseException as e: # skip single post parse exception 95 | logger.warn(e.__doc__ + ', filepath: %r' % filepath) 96 | else: 97 | self.posts.append(Post(**data)) 98 | 99 | 100 | # sort posts by its created time, from new to old 101 | self.posts.sort(key=lambda post: post.datetime.timetuple(), reverse=True) 102 | 103 | count = len(self.posts) 104 | 105 | for idx, post in enumerate(self.posts): 106 | 107 | if idx == 0: 108 | _next = None 109 | else: 110 | _next = self.posts[idx-1] 111 | 112 | if idx == count - 1: 113 | _prev = None 114 | else: 115 | _prev = self.posts[idx+1] 116 | 117 | setattr(post, 'next', _next) 118 | setattr(post, 'prev', _prev) 119 | 120 | def parse_posts(self): 121 | 122 | for post in self.posts: 123 | 124 | with open(post.filepath, 'r') as file: 125 | content = file.read() 126 | 127 | try: 128 | data = parser.parse(content) 129 | except ParseException, e: 130 | logger.warn(e.__doc__ + ', filepath %r' % post.filepath) 131 | pass 132 | else: 133 | post.__dict__.update(data) 134 | 135 | def get_pages(self): 136 | 137 | groups = chunks(self.posts, self.POSTS_COUNT_EACH_PAGE) 138 | self.pages = [Page(number=idx, posts=list(group)) 139 | for idx, group in enumerate(groups, 1)] 140 | 141 | if self.pages: 142 | self.pages[0].first = True 143 | self.pages[-1].last = True 144 | 145 | def render(self): 146 | 147 | mkdir_p(Post.out_dir) 148 | mkdir_p(Page.out_dir) 149 | 150 | for page in self.pages: 151 | for post in page.posts: 152 | render_to(post.out, Post.template, post=post) 153 | render_to(page.out, Page.template, page=page) 154 | 155 | def generate(self): 156 | start_time = time.time() 157 | self.initialize() 158 | self.get_posts() 159 | self.get_pages() 160 | self.parse_posts() 161 | self.render() 162 | 163 | logger.success("Build done in %.3f seconds" % (time.time() - start_time)) 164 | 165 | def re_generate(self): 166 | self.reset() 167 | self.generate() 168 | 169 | 170 | generator = Generator() 171 | -------------------------------------------------------------------------------- /rux/daemon.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.daemon 5 | ~~~~~~~~~~ 6 | 7 | rux's http server and wacher daemon, it runs rux in the background. This 8 | daemon is modified from 's generic daemon class. 9 | """ 10 | 11 | 12 | import atexit 13 | import logging 14 | import os 15 | import signal 16 | import sys 17 | import time 18 | 19 | from .logger import logger 20 | from .server import server 21 | 22 | 23 | class Daemon(object): 24 | 25 | def __init__(self, pidfile, stdin=os.devnull, stdout=os.devnull, 26 | stderr=os.devnull, home_dir='.', umask=022): 27 | self.pidfile = pidfile 28 | self.stdin = stdin 29 | self.stdout = stdout 30 | self.stderr = stderr 31 | self.home_dir = home_dir 32 | self.umask = umask 33 | self.daemon_alive = True 34 | 35 | def daemonize(self, server_port): 36 | try: 37 | pid = os.fork() 38 | if pid > 0: 39 | sys.exit(0) 40 | except OSError, e: 41 | sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, 42 | e.strerror)) 43 | sys.exit(1) 44 | 45 | os.chdir(self.home_dir) 46 | os.setsid() 47 | os.umask(self.umask) 48 | 49 | try: 50 | pid = os.fork() 51 | if pid > 0: 52 | sys.exit(0) 53 | except OSError, e: 54 | sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, 55 | e.strerror)) 56 | sys.exit(1) 57 | 58 | if sys.platform != 'darwin': 59 | sys.stdout.flush() 60 | sys.stderr.flush() 61 | si = file(self.stdin, 'r') 62 | so = file(self.stdout, 'a+') 63 | if self.stderr: 64 | se = file(self.stderr, 'a+', 0) 65 | else: 66 | se = so 67 | os.dup2(si.fileno(), sys.stdin.fileno()) 68 | os.dup2(so.fileno(), sys.stdout.fileno()) 69 | os.dup2(se.fileno(), sys.stderr.fileno()) 70 | 71 | def sigtermhandler(signum, frame): 72 | self.daemon_alive = False 73 | signal.signal(signal.SIGTERM, sigtermhandler) 74 | signal.signal(signal.SIGINT, sigtermhandler) 75 | 76 | logger.success('Started') 77 | 78 | # Write pidfile 79 | atexit.register(self.delpid) # Make sure pid file is removed if we \ 80 | # quit 81 | pid = str(os.getpid()) 82 | file(self.pidfile, 'w+').write("%s:%s\n" % (pid, server_port)) 83 | 84 | def delpid(self): 85 | os.remove(self.pidfile) 86 | 87 | def start(self, server_port): 88 | logger.info('Starting http server(0.0.0.0:%d)' 89 | ' and source files watcher..' % server_port) 90 | 91 | try: 92 | pf = file(self.pidfile, 'r') 93 | pid, port = map(int, pf.read().strip().split(':')) 94 | pf.close() 95 | except (IOError, SystemExit) as e: 96 | pid, port = None, None 97 | 98 | if pid and port: 99 | message = ('pidfile %s already exists(pid: %d, port: %d). Is it already running?') 100 | logger.warning(message % (self.pidfile, pid, port)) 101 | sys.exit(1) 102 | 103 | self.daemonize(server_port) 104 | self.run(server_port) 105 | 106 | def stop(self): 107 | logger.info('Stopping the daemon..') 108 | 109 | try: 110 | pf = file(self.pidfile) 111 | pid, port = map(int, pf.read().strip().split(':')) 112 | pf.close() 113 | except (IOError, ValueError) as e: 114 | pid, port = None, None 115 | 116 | if not (pid and port): 117 | message = 'pidfile %s does not exist. Not running?' 118 | logger.warning(message % self.pidfile) 119 | 120 | if os.path.exists(self.pidfile): 121 | os.remove(self.pidfile) 122 | 123 | return # Not an error in a restart process 124 | 125 | # Try killing the daemon process 126 | try: 127 | i = 0 128 | while 1: 129 | os.kill(pid, signal.SIGTERM) 130 | time.sleep(0.1) 131 | i = i + 1 132 | if i % 10 == 0: 133 | os.kill(pid, signal.SIGHUP) 134 | except OSError, err: 135 | err = str(err) 136 | if err.find("No such process") > 0: 137 | if os.path.exists(self.pidfile): 138 | os.remove(self.pidfile) 139 | else: 140 | logger.error(str(err)) 141 | sys.exit(1) 142 | 143 | logger.success('Stopped.') 144 | 145 | def status(self): 146 | 147 | try: 148 | pf = file(self.pidfile) 149 | pid, port = map(int, pf.read().strip().split(':')) 150 | pf.close() 151 | except (IOError, ValueError) as e: 152 | pid, port = None, None 153 | 154 | if pid and port: 155 | logger.info('Running: 0.0.0.0:%d, pid: %d.' % (port, pid)) 156 | else: 157 | logger.info('Stopped.') 158 | 159 | def run(self, port): 160 | logger.setLevel(logging.ERROR) 161 | server.run(port) 162 | logger.setLevel(logging.INFO) 163 | 164 | 165 | daemon = Daemon("/tmp/rux-daemon.pid", stdout="/dev/stdout") 166 | -------------------------------------------------------------------------------- /rux/server.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | 3 | """ 4 | rux.server 5 | ~~~~~~~~~~ 6 | 7 | rux's server, include a web server and a watcher, running in two threads, 8 | the file watcher will watch source files updates and start building process 9 | automatically, the http server host the static site at 0.0.0.0:port 10 | """ 11 | 12 | from BaseHTTPServer import BaseHTTPRequestHandler 13 | from BaseHTTPServer import HTTPServer 14 | import logging 15 | from os import listdir as ls 16 | from os import stat, getcwd 17 | from os.path import exists, relpath 18 | import posixpath 19 | from SimpleHTTPServer import SimpleHTTPRequestHandler 20 | import socket 21 | from SocketServer import ThreadingMixIn 22 | import sys 23 | from threading import Thread 24 | from time import sleep 25 | 26 | from . import src_ext 27 | from .config import config 28 | from .exceptions import SourceDirectoryNotFound 29 | from .generator import generator 30 | from .logger import logger 31 | from .models import Post 32 | from .utils import join 33 | 34 | _root = '' 35 | 36 | class Handler(SimpleHTTPRequestHandler): 37 | 38 | def log_message(self, format, *args): 39 | # http server's output message formatter 40 | logger.info("%s - %s" % (self.address_string(), format % args)) 41 | 42 | def translate_path(self, path): 43 | if not path.startswith(_root): 44 | path = _root 45 | path_ = join(getcwd(), relpath(path, _root or '/')) 46 | path_ = path_.split('?')[0] 47 | path_ = path_.split('#')[0] 48 | return posixpath.normpath(path_) 49 | 50 | 51 | class MultiThreadedHTTPServer(ThreadingMixIn, HTTPServer): 52 | """Multiple threaded http server""" 53 | pass 54 | 55 | 56 | class Server(object): 57 | """rux's server, include a web server to host this static blog at localhost 58 | , and a files watcher to automatically once source files updated""" 59 | 60 | def __init__(self): 61 | self.files_stat = {} # dict, {filepath: file updated time} 62 | self.server = None # instance of `MultiThreadedHTTPServer` 63 | self.watcher = Thread(target=self.watch_files) # the thread of watcher 64 | self.watcher.daemon = True # terminate watcher once main process ends 65 | 66 | def run_server(self, port): 67 | """run a server binding to port""" 68 | 69 | try: 70 | self.server = MultiThreadedHTTPServer(('0.0.0.0', port), Handler) 71 | except socket.error, e: # failed to bind port 72 | logger.error(str(e)) 73 | sys.exit(1) 74 | 75 | logger.info("HTTP serve at http://0.0.0.0:%d (ctrl-c to stop) ..." 76 | % port) 77 | 78 | try: 79 | self.server.serve_forever() 80 | except KeyboardInterrupt: 81 | logger.info("^C received, shutting down server") 82 | self.shutdown_server() 83 | 84 | def get_files_stat(self): 85 | """get source files' update time""" 86 | 87 | if not exists(Post.src_dir): 88 | logger.error(SourceDirectoryNotFound.__doc__) 89 | sys.exit(SourceDirectoryNotFound.exit_code) 90 | 91 | paths = [] 92 | 93 | for fn in ls(Post.src_dir): 94 | if fn.endswith(src_ext): 95 | paths.append(join(Post.src_dir, fn)) 96 | 97 | # config.toml 98 | if exists(config.filepath): 99 | paths.append(config.filepath) 100 | 101 | # files: a dict 102 | files = dict((p, stat(p).st_mtime) for p in paths) 103 | return files 104 | 105 | def watch_files(self): 106 | """watch files for changes, if changed, rebuild blog. this thread 107 | will quit if the main process ends""" 108 | 109 | try: 110 | while 1: 111 | sleep(1) # check every 1s 112 | 113 | try: 114 | files_stat = self.get_files_stat() 115 | except SystemExit: 116 | logger.error("Error occurred, server shut down") 117 | self.shutdown_server() 118 | 119 | if self.files_stat != files_stat: 120 | logger.info("Changes detected, start rebuilding..") 121 | 122 | try: 123 | generator.re_generate() 124 | global _root 125 | _root = generator.root 126 | except SystemExit: # catch sys.exit, it means fatal error 127 | logger.error("Error occurred, server shut down") 128 | self.shutdown_server() 129 | 130 | self.files_stat = files_stat # update files' stat 131 | except KeyboardInterrupt: 132 | # I dont know why, but this exception won't be catched 133 | # because absolutly each KeyboardInterrupt is catched by 134 | # the server thread, which will terminate this thread the same time 135 | logger.info("^C received, shutting down watcher") 136 | self.shutdown_watcher() 137 | 138 | def run(self, port): 139 | """start web server and watcher""" 140 | self.watcher.start() 141 | self.run_server(port) 142 | 143 | def shutdown_server(self): 144 | """shut down the web server""" 145 | self.server.shutdown() 146 | self.server.socket.close() 147 | 148 | def shutdown_watcher(self): 149 | """shut down the watcher thread""" 150 | self.watcher.join() 151 | 152 | 153 | server = Server() 154 | --------------------------------------------------------------------------------