#!/usr/bin/env python3 """Thrust is a static site generator.""" import sys import textwrap from typing import Any, Dict import jinja2 import jinja2.ext import jinja2.nodes import markdown import yaml # cribbed from https://jinja.palletsprojects.com/en/2.11.x/extensions/#cache. class MarkdownExtension(jinja2.ext.Extension): """MarkdownExtension provides {% markdown %} and {% endmarkdown %}.""" # a set of names that trigger the extension. tags = {'markdown'} def __init__(self, environment): super(MarkdownExtension, self).__init__(environment) # add the defaults to the environment environment.extend(markdown=markdown.Markdown()) def parse(self, parser): # the first token is the token that started the tag. # we only listen to `markdown`, so it will always be that. # we keep the line number for the nodes we create later on. lineno = next(parser.stream).lineno # parse the body of the `{% markdown %}` up to `{% endmarkdown %}`. body = parser.parse_statements(['name:endmarkdown'], drop_needle=True) # return a `CallBlock` node that calls self._markdown_support. return jinja2.nodes.CallBlock( self.call_method('_markdown_support'), [], [], body).set_lineno(lineno) def _markdown_support(self, caller): """Helper callback.""" block = caller() block = textwrap.dedent(block) return self.environment.markdown.convert(block) class Thrust: """Thrust is a document templater.""" def __init__(self, template: str, data: Dict[str, Any]): self.template = template self.data = data @classmethod def from_path(cls, path: str) -> 'Thrust': with open(path) as f: return cls.parse(f.read()) @classmethod def parse(cls, src: str) -> 'Thrust': """Parse a source file to maybe return a valid Thrust object.""" lines = src.split('\n') parts = [] for line in lines: if line == '---': parts.append([]) else: parts[-1].append(line) if len(parts) != 2: raise ValueError( f'file needs 2 "---" deliniated parts, found {len(parts)}') data = yaml.safe_load('\n'.join(parts[0]).replace('\t', ' ')) if data is None: data = {} template = '\n'.join(parts[1]) return cls(template, data) @property def _environment(self) -> jinja2.Environment: environment = jinja2.Environment( loader=jinja2.FileSystemLoader('.'), extensions=[MarkdownExtension], trim_blocks=True, ) environment.markdown = markdown.Markdown( extensions=['attr_list', 'extra', 'toc']) def loadthrust_filter(path: str) -> str: return Thrust.from_path(path) environment.filters['loadthrust'] = loadthrust_filter def markdown_filter(src: str) -> str: return environment.markdown.convert(src) environment.filters['markdown'] = markdown_filter def template_filter(tmpl: str) -> str: return environment.from_string(tmpl).render(self.data) environment.filters['template'] = template_filter def prefix_filter(s: str, prefix: str, first=True) -> str: if not first: lines = s.splitlines() return '\n'.join(lines[0:1] + [f'{prefix}{l}' for l in lines[1:]]) return '\n'.join([f'{prefix}{l}' for l in s.splitlines()]) environment.filters['prefix'] = prefix_filter return environment def render(self) -> str: """Render the data in the template.""" return self._environment.from_string(self.template).render(self.data) if __name__ == '__main__': if len(sys.argv) != 2: print(f'usage: {sys.argv[0]} ') sys.exit(1) in_path = sys.argv[1] try: thrust = Thrust.from_path(in_path) print(thrust.render()) except ValueError as error: print(f'{in_path}: {error}', file=sys.stderr) sys.exit(1) except jinja2.exceptions.TemplateError as error: print(error, file=sys.stderr) sys.exit(1)