Пример #1
0
 def _generate(self):
     logger.debug('>> Initializing\n..  src:  %s\n..  dest: %s', self.src.path, self.dest.path)
     
     self._update_config()
     
     if self.config['locale']:
         try:
             locale.setlocale(locale.LC_ALL, (self.config['locale'], 'utf-8'))
         except locale.Error:
             raise ConfigException('Locale not available.', 'run `locale -a` to see available locales')
     
     self.renderer.register({'site': self.config})
     
     self._render()
     
     logger.info('>> Generating')
     
     assets_src = Directory(normpath(self.src.path, '_assets'))
     assets_dest = Directory(normpath(self.dest.path, *self.config['assets_url'].split('/')))
     
     if self.dest.exists:
         if self.opts['force']:
             self.dest.empty()
         else:
             self.dest.rm()
     else:
         self.dest.mk()
     
     for page in self.pages:
         page.mk()
     
     assets_src.cp(assets_dest.path)
     
     for pattern in self.config['include']:
         for path in iglob(normpath(self.src.path, pattern)):
             dest = path.replace(self.src.path, self.dest.path)
             
             if op.isdir(path):
                 Directory(path).cp(dest, False)
             elif op.isfile(path):
                 File(path).cp(dest)
     
     logger.info('Completed in %.3fs', time() - self._start)
Пример #2
0
class Fjord(object):
    defaults = {
        'archive_layout': None,
        'archives_url': '/',
        'assets_url': '/assets/',
        'base_url': '/',
        'date_format': '%A, %B %d, %Y',
        'domain': None,
        'include': [],
        'locale': None,
        'markup': 'markdown',
        'parser': 'misaka',
        'posts_url': '/<year>/<month>/<day>/<title>/',
        'pygmentize': True,
        'renderer': 'jinja',
        'tag_layout': None,
        'tags_url': '/',
        'version': __version__
    }
    
    _parser = None
    _renderer = None
    
    archives = OrderedDict()
    config = {}
    pages = []
    posts = []
    drafts = []
    ignores = []
    tags = OrderedDict()
    
    
    def __init__(self, args = None):
        self._start = time()
        
        self.opts = self._get_opts(args)
        
        logger.setLevel(getattr(logging, self.opts['level'], logging.INFO))
        
        self.opts['func']()
    
    
    def _archive(self, posts):
        archives = OrderedDict()
        
        for post in posts:
            year, month = datetime.utcfromtimestamp(post['timestamp']).strftime('%Y %B').decode('utf-8').split()
            
            if year not in archives:
                archives[year] = {
                    'months': OrderedDict({month: [post]}),
                    'url': self._get_archive_url(year),
                    'year': year
                }
            elif month not in archives[year]['months']:
                archives[year]['months'][month] = [post]
            else:
                archives[year]['months'][month].append(post)
        
        return archives
    
    def _get_archive_url(self, year):
        format = self._get_url_format(self.config['archives_url'].endswith('/'))
        
        return format.format(self.config['archives_url'], year)
    
    def _get_opts(self, args):
        opts = {}
        
        parser = ArgumentParser(description = 'A static blog generator.')
        sub = parser.add_subparsers()
        
        level = parser.add_mutually_exclusive_group()
        
        level.add_argument('-l', '--level', default = b'INFO', type = str.upper, choices = [b'DEBUG', b'INFO', b'WARNING', b'ERROR'], help = 'Sets %(prog)s\'s log level.')
        level.add_argument('-q', '--quiet', action = 'store_const', const = 'ERROR', dest = 'level', help = 'Sets %(prog)s\'s log level to ERROR.')
        level.add_argument('-v', '--verbose', action = 'store_const', const = 'DEBUG', dest = 'level', help = 'Sets %(prog)s\'s log level to DEBUG.')
        
        parser.add_argument('-V', '--version', action = 'version', version = '%(prog)s v{0}'.format(__version__), help = 'Prints %(prog)s\'s version and exits.')
        
        gen = sub.add_parser('gen')
        
        gen.add_argument('src', nargs = '?', default = '.', metavar = 'source', help = 'The directory %(prog)s looks in for source files.')
        gen.add_argument('dest', metavar = 'destination', help = 'The directory %(prog)s outputs to.')
        
        gen.add_argument('--base-url', help = 'Sets the site\'s base URL overriding the config setting.')
        gen.add_argument('--locale', help = 'Sets the locale used by the renderer.')
        
        force = gen.add_mutually_exclusive_group()
        
        force.add_argument('-c', '--clean', action = 'store_true', help = 'Forces generation by deleting the destination if it exists.')
        force.add_argument('-f', '--force', action = 'store_true', help = 'Forces generation by emptying the destination if it exists.')
        
        gen.set_defaults(func = self.generate)
        
        init = sub.add_parser('init')
        
        init.add_argument('dest', metavar = 'destination', help = 'The directory %(prog)s outputs to.')
        
        init.add_argument('--bare', action = 'store_true', help = 'Initializes a new site without using a theme.')
        init.add_argument('-f', '--force', action = 'store_true', help = 'Forces initialization by deleting the destination if it exists.')
        init.add_argument('-t', '--theme', default = 'dark', help = 'Sets which theme will be used.')
        
        init.set_defaults(func = self.init)
        
        serve = sub.add_parser('serve')
        
        serve.add_argument('src', nargs = '?', default = '.', metavar = 'source', help = 'The directory %(prog)s will serve.')
        
        serve.add_argument('--base-url', default = '/', help = 'Sets the site\'s base URL overriding the config setting.')
        serve.add_argument('-p', '--port', default = 8080, type = int, help = 'Sets the port used by the server.')
        
        serve.set_defaults(func = self.serve)
        
        watch = sub.add_parser('watch')
        
        watch.add_argument('src', nargs = '?', default = '.', metavar = 'source', help = 'The directory %(prog)s looks in for source files.')
        watch.add_argument('dest', metavar = 'destination', help = 'The directory %(prog)s outputs to.')
        
        watch.add_argument('--base-url', help = 'Sets the site\'s base URL overriding the config setting.')
        watch.add_argument('-f', '--force', action = 'store_true', help = 'Forces watching by emptying the destination every time a change is made if it exists.')
        watch.add_argument('--locale', help = 'Sets the locale used by the renderer.')
        
        watch.set_defaults(func = self.watch)
        
        for option, value in vars(parser.parse_args(args)).iteritems():
            if value is not None:
                if isinstance(option, str):
                    option = option.decode('utf-8')
                
                if isinstance(value, str):
                    value = value.decode('utf-8')
                
                opts[option] = value
        
        return opts
    
    def _get_parser(self):
        try:
            return load_entry_point('fjord', 'fjord.parsers.{0}'.format(self.config['markup']), self.config['parser'])
        except ImportError:
            return __import__('fjord.parsers.{0}.{1}'.format(self.config['markup'], self.config['parser']), globals(), locals(), ['Parser'], -1).Parser
    
    def _get_path(self, url):
        parts = [self.dest.path] + url.split('/')
        
        if url.endswith('/'):
            parts.append('index.html')
        
        return normpath(*parts)
    
    def _get_post_url(self, date, slug):
        subs = {
            '<year>': '%Y',
            '<month>': '%m',
            '<day>': '%d',
            '<i_month>': unicode(date.month),
            '<i_day>': unicode(date.day),
            '<title>': self._slugify(slug)
        }
        
        url = self.config['posts_url'].replace('%', '%%')
        
        for match, replace in subs.iteritems():
            url = url.replace(match, replace)
        
        return date.strftime(url).decode('utf-8')
    
    def _get_renderer(self):
        try:
            return load_entry_point('fjord', 'fjord.renderers', self.config['renderer'])
        except ImportError:
            return __import__('fjord.renderers.{0}'.format(self.config['renderer']), globals(), locals(), ['Renderer'], -1).Renderer
    
    def _get_tag_url(self, name):
        format = self._get_url_format(self.config['tags_url'].endswith('/'))
        
        return format.format(self.config['tags_url'], self._slugify(name))
    
    def _get_theme(self, theme):
        return resource_filename(__name__, 'themes/{0}'.format(theme))
    
    def _get_url_format(self, clean):
        return '{0}{1}/' if clean else '{0}/{1}.html'
    
    def _highlight(self, match):
            language, code = match.groups()
            formatter = HtmlFormatter(linenos=True)
            
            code = h.unescape_html(code.encode('utf-8')).decode('utf-8')
            
            try:
                code = highlight(code, get_lexer_by_name(language), formatter)
            except ClassNotFound:
                code = highlight(code, get_lexer_by_name('text'), formatter)
            
            return '<div class="code"><div>{0}</div></div>'.format(code)
    
    def _pygmentize(self, html):
        if not self.config['pygmentize']:
            return html
        
        return re.sub(r'<pre><code[^>]+data-lang="([^>]+)"[^>]*>(.+?)</code></pre>', self._highlight, html, flags = re.S)
    
    def _slugify(self, text):
        slug = re.sub(r'\s+', '-', text.strip())
        slug = re.sub(r'[^a-z0-9\-_.]', '', slug, flags = re.I)
        
        if slug == '..':
            raise PageException('Invalid slug.')
        
        return slug
    
    def _update_config(self):
        self.config = deepcopy(self.defaults)
        
        logger.debug('>> Searching for config')
        
        for ext in ('.yml', '.yaml'):
            f = File(normpath(self.src.path, 'config' + ext))
            
            if f.exists:
                logger.debug('..  found: %s', f.path)
                
                try:
                    self.config.update(Config(f.content))
                except ConfigException as e:
                    raise ConfigException(e.message, 'src: {0}'.format(f.path))
                
                self.config['locale'] = self.opts.get('locale', self.config['locale'])
                
                self.config['assets_url'] = absurl(self.config['assets_url'], '')
                self.config['base_url'] = absurl(self.opts.get('base_url', self.config['base_url']), '')
                
                for setting in ('archives_url', 'posts_url', 'tags_url'):
                    self.config[setting] = absurl(self.config[setting])
                
                for setting in ('archives_url', 'assets_url', 'base_url', 'posts_url', 'tags_url'):
                    if re.search(r'(?:^\.{2}/|/\.{2}$|/\.{2}/)', self.config[setting]):
                        raise ConfigException('Invalid config setting.', 'setting: {0}'.format(setting), 'path traversal is not allowed')
                
                for pattern in self.config['include']:
                    if op.commonprefix((self.src.path, normpath(self.src.path, pattern))) != self.src.path:
                        raise ConfigException('Invalid include path.', 'path: {0}'.format(pattern), 'path traversal is not allowed')
                
                break
        else:
            logger.debug('..  no config file found')
    
    
    def _parse(self):
        logger.info('>> Parsing')
        
        path = Directory(normpath(self.src.path, '_posts'))
        
        logger.debug('..  src: %s', path)
        
        for i, f in enumerate(path):
            post = Post(f)
            
            content = self.parser.parse(self.renderer.from_string(post.bodymatter, post.frontmatter))
            excerpt = re.search(r'\A.*?(?:<p>(.+?)</p>)?', content, re.M | re.S).group(1)
            
            try:
                data = {
                    'content': content,
                    'date': post.date.strftime(self.config['date_format']).decode('utf-8'),
                    'excerpt': excerpt,
                    'tags': [],
                    'timestamp': timegm(post.date.utctimetuple()),
                    'url': self._get_post_url(post.date, post.slug),
                    'prev': False,
                    'next': False
                }
            except PageException:
                raise PageException('Invalid post slug.', 'src: {0}'.format(post.path))
            
            data.update(post.frontmatter)
            if len(data['tags']) == 0:
                data['tags'].append('_untagged') 
            else:
                data['tags'].sort()
            
            data['tags'] = [tag.title() for tag in data['tags']]

            data['title'] = str(data['title'])

            if post.draft:
                data['url'] = '/drafts/' + self._slugify(post.slug)
                self.drafts.append(data)
            else:
                self.posts.append(data)
                
                for tag in data['tags']:
                    if tag not in self.tags:
                        self.tags[tag] = []
                    
                    self.tags[tag].append(data)

        self.posts.sort(key = lambda post: post['timestamp'], reverse = True)
        for post in self.posts:
            if self.posts.index(post) != len(self.posts) - 1:
                prev = self.posts[self.posts.index(post) + 1]
                post['prev'] = {'title': prev['title'], 'url': prev['url']}
            
            if self.posts.index(post) != 0:
                next = self.posts[self.posts.index(post) - 1]
                post['next'] = {'title': next['title'], 'url': next['url']}


    def _process(self):
        self._parse()
        
        logger.info('>> Processing')
        
        if self.posts:
            logger.debug('..  ordering posts')
            
            self.posts.sort(key = lambda post: post['timestamp'], reverse = True)
            
            logger.debug('..  generating archives')
            
            self.archives = self._archive(self.posts)
            
            logger.debug('..  sorting tags')
            
            tags = []
            
            for name, posts in self.tags:
                posts.sort(key = lambda post: post['timestamp'], reverse = True)
                
                try:
                    tags.append({
                        'archives': self._archive(posts),
                        'count': len(posts),
                        'name': name,
                        'posts': posts,
                        'url': self._get_tag_url(name)
                    })
                except PageException:
                    message = ['tag: {0}'.format(name)]
                    
                    for post in posts:
                        message.append('post: {0}'.format(post.get('title', post['url'])))
                    
                    raise PageException('Invalid tag slug.', *message)
            
            tags.sort(key = lambda tag: tag['name'].lower())
            
            self.tags.clear()
            
            for tag in tags:
                self.tags[tag['name']] = tag
        else:
            logger.debug('..  no posts found')
    
    def _render(self):
        self._process()
        
        logger.info('>> Rendering')
        
        self.renderer.register({
            'archives': self.archives,
            'posts': self.posts,
						'tags': self.tags
        })
        
        logger.debug('..  posts')
        
        for post in self.posts:
            try:
                self.pages.append(Page(
                    self._get_path(post['url']),
                    self._pygmentize(self.renderer.render(post['layout'], {'post': post}))
                ))
            except RendererException as e:
                raise RendererException(e.message, '{0} in post \'{1}\''.format(post['layout'], post['title']))
        
        logger.debug('..  drafts')
        for draft in self.drafts:
            try:
                self.pages.append(Page(
                    self._get_path(draft['url']),
                    self._pygmentize(self.renderer.render(post['layout'], {'post': post}))
                ))
            except RendererException as e:
                raise RendererException(e.message, '{0} in draft\'{1}\''.format(draft['layout'], draft['title']))

        logger.debug('..  pages')
        
        for f in self.src:
            if f.extension not in ('.html', '.htm', '.xml'):
                continue
            
            template = f.path.replace(self.src.path, '')
            
            self.pages.append(Page(
                normpath(self.dest.path, template),
                self._pygmentize(self.renderer.render(template))
            ))
        
        if self.config['tag_layout'] and self.tags:
            logger.debug('..  tags')
            
            for name, data in self.tags:
                self.pages.append(Page(
                    self._get_path(data['url']),
                    self._pygmentize(self.renderer.render(self.config['tag_layout'], {'tag': data}))
                ))
        
        if self.config['archive_layout'] and self.archives:
            logger.debug('..  archives')
            
            for year, data in self.archives:
                self.pages.append(Page(
                    self._get_path(data['url']),
                    self._pygmentize(self.renderer.render(self.config['archive_layout'], {'archive': data}))
                ))
    
    def _generate(self):
        logger.debug('>> Initializing\n..  src:  %s\n..  dest: %s', self.src.path, self.dest.path)
        
        self._update_config()
        
        if self.config['locale']:
            try:
                locale.setlocale(locale.LC_ALL, (self.config['locale'], 'utf-8'))
            except locale.Error:
                raise ConfigException('Locale not available.', 'run `locale -a` to see available locales')
        
        self.renderer.register({'site': self.config})
        
        self._render()
        
        logger.info('>> Generating')
        
        assets_src = Directory(normpath(self.src.path, '_assets'))
        assets_dest = Directory(normpath(self.dest.path, *self.config['assets_url'].split('/')))
        
        if self.dest.exists:
            if self.opts['force']:
                self.dest.empty()
            else:
                self.dest.rm()
        else:
            self.dest.mk()
        
        for page in self.pages:
            page.mk()
        
        assets_src.cp(assets_dest.path)
        
        for pattern in self.config['include']:
            for path in iglob(normpath(self.src.path, pattern)):
                dest = path.replace(self.src.path, self.dest.path)
                
                if op.isdir(path):
                    Directory(path).cp(dest, False)
                elif op.isfile(path):
                    File(path).cp(dest)
        
        logger.info('Completed in %.3fs', time() - self._start)
    
    def _regenerate(self):
        self._parser = None
        self._renderer = None
        self._start = time()
        
        self.archives = OrderedDict()
        self.config = {}
        self.pages = []
        self.posts = []
        self.tags = OrderedDict()
        
        self._generate()
        
        logger.info('Regenerated in %.3fs', time() - self._start)
    
    
    def generate(self):
        self.src = Directory(self.opts['src'])
        self.dest = Directory(self.opts['dest'])
        
        if not self.src.exists:
            raise OptionException('Source must exist.')
        elif self.src == self.dest:
            raise OptionException('Source and destination must differ.')
        elif self.dest.exists and not (self.opts['force'] or self.opts['clean']):
            raise OptionException('Destination already exists.', 'the -c or -f flag must be passed to force generation by deleting or emptying the destination')
        
        self._generate()
    
    def init(self):
        self.src = Directory(self._get_theme(self.opts['theme']))
        self.dest = Directory(self.opts['dest'])
        
        if not self.src.exists:
            raise OptionException('Theme not found.')
        elif self.dest.exists and not self.opts['force']:
            raise OptionException('Destination already exists.', 'the -f flag must be passed to force initialization by deleting the destination')
        
        logger.info('>> Initializing')
        
        if self.opts['bare']:
            self.dest.rm()
            
            for d in ('_assets/css', '_assets/images', '_assets/js', '_templates', '_posts'):
                Directory(normpath(self.dest.path, d)).mk()
            
            File(normpath(self.dest.path, 'config.yml')).mk()
        else:
            self.src.cp(self.dest.path, False)
        
        logger.info('Completed in %.3fs', time() - self._start)
    
    def serve(self):
        self.src = Directory(self.opts['src'])
        base_url = absurl(self.opts['base_url'], '')
        
        if not self.src.exists:
            raise OptionException('Source must exist.')
        
        logger.info('>> Serving at 127.0.0.1:%s', self.opts['port'])
        logger.info('Press ctrl+c to stop.')
        
        cwd = getcwd()
        self.server = Server(('', self.opts['port']), base_url, RequestHandler)
        
        chdir(self.src.path)
        
        try:
            self.server.serve_forever()
            self.watch()
        except KeyboardInterrupt:
            self.server.shutdown()
            chdir(cwd)
            
            print('')
    
    def watch(self):
        self.src = Directory(self.opts['src'])
        self.dest = Directory(self.opts['dest'])
        
        if not self.src.exists:
            raise OptionException('Source must exist.')
        elif self.src == self.dest:
            raise OptionException('Source and destination must differ.')
        elif self.dest.exists and not self.opts['force']:
            raise OptionException('Destination already exists.', 'the -f flag must be passed to force watching by emptying the destination every time a change is made')
        
        logger.info('>> Watching')
        logger.info('Press ctrl+c to stop.')
        
        self.observer = Observer()
        
        self.observer.schedule(EventHandler(self.src.path, self._regenerate), self.src.path, True)
        self.observer.start()
        
        try:
            while True:
                sleep(1)
        except KeyboardInterrupt:
            self.observer.stop()
            
            print('')
        
        self.observer.join()
    
    
    @property
    def parser(self):
        if self._parser is None:
            self._parser = self._get_parser()(self.config.get(self.config['parser'], {}))
        
        return self._parser
    
    @property
    def renderer(self):
        if self._renderer is None:
            self._renderer = self._get_renderer()(self.src.path, self.config.get(self.config['renderer'], {}))
        
        return self._renderer