aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMelody Horn <melody@boringcactus.com>2020-11-17 16:21:00 -0700
committerMelody Horn <melody@boringcactus.com>2020-11-17 16:21:00 -0700
commitc49b0a18592ec2145db65c38073d652fd5dab20b (patch)
tree8b7583c21d3dad7ac6d672a2affcac65a82790bc
downloadcactus-ssg-c49b0a18592ec2145db65c38073d652fd5dab20b.tar.gz
cactus-ssg-c49b0a18592ec2145db65c38073d652fd5dab20b.zip
write generator
-rw-r--r--.gitignore1
-rw-r--r--LICENSE.md52
-rw-r--r--build.py193
-rw-r--r--requirements.txt5
4 files changed, 251 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f9606a3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/venv
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..4cd3026
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,52 @@
+# The Fuck Around and Find Out License, version 0.2
+
+## Purpose
+
+This license gives everyone as much permission to work with
+this software as possible, while protecting contributors
+from liability, and ensuring this software is used
+ethically.
+
+## Acceptance
+
+In order to receive this license, you must agree to its
+rules. The rules of this license are both obligations
+under that agreement and conditions to your license.
+You must not do anything with this software that triggers
+a rule that you cannot or will not follow.
+
+## Copyright
+
+Each contributor licenses you to do everything with this
+software that would otherwise infringe that contributor's
+copyright in it.
+
+## Ethics
+
+This software must be used for Good, not Evil, as
+determined by the primary contributors to the software.
+
+## Excuse
+
+If anyone notifies you in writing that you have not
+complied with [Ethics](#ethics), you can keep your
+license by taking all practical steps to comply within 30
+days after the notice. If you do not do so, your license
+ends immediately.
+
+## Patent
+
+Each contributor licenses you to do everything with this
+software that would otherwise infringe any patent claims
+they can license or become able to license.
+
+## Reliability
+
+No contributor can revoke this license.
+
+## No Liability
+
+***As far as the law allows, this software comes as is,
+without any warranty or condition, and no contributor
+will be liable to anyone for any damages related to this
+software or this license, under any kind of legal claim.***
diff --git a/build.py b/build.py
new file mode 100644
index 0000000..7d71b9f
--- /dev/null
+++ b/build.py
@@ -0,0 +1,193 @@
+from dataclasses import dataclass
+from datetime import date, datetime
+from pathlib import Path
+import re
+import shutil
+import typing
+
+from jinja2 import Environment, FileSystemLoader, Markup, StrictUndefined, Template, select_autoescape
+from md2gemini import md2gemini
+import mistune
+import mistune_contrib.highlight
+
+POST_DATE = re.compile(r'(\d{4}-\d{2}-\d{2})-(.*)\.md')
+POST_FRONT_MATTER = re.compile(r'---\n(.*?)---\n\n?(.*)', re.DOTALL)
+
+html_template = Template('')
+gmi_template = Template('')
+
+
+@dataclass
+class Page:
+ title: str
+ description: typing.Optional[str]
+ excerpt: str
+ url: str
+ date: typing.Optional[date]
+
+ raw_content: str
+ html_content: str
+ gmi_content: str
+
+ @staticmethod
+ def load(path: Path, *, template_context: typing.Optional[dict] = None):
+ date_match = POST_DATE.fullmatch(path.name)
+ if date_match is None:
+ slug = path.stem
+ dest = Path(slug)
+ page_date = None
+ else:
+ post_date, slug = date_match.groups()
+ page_date = date.fromisoformat(post_date)
+ dest = Path(page_date.strftime('%Y/%m/%d')) / slug
+
+ file_data = path.read_text()
+ front_matter = POST_FRONT_MATTER.fullmatch(file_data)
+ if front_matter is not None:
+ front_matter, content = POST_FRONT_MATTER.fullmatch(file_data).groups()
+ front_matter = parse_front_matter(front_matter)
+ else:
+ front_matter = {'title': slug}
+ content = file_data
+ title = front_matter['title']
+ description = front_matter.get('description', None)
+ excerpt = get_excerpt(content)
+ if 'permalink' in front_matter:
+ permalink = front_matter['permalink'].lstrip('/')
+ if permalink.endswith('/'):
+ permalink = permalink + 'index'
+ dest = Path(permalink)
+
+ if template_context is not None:
+ content = Template(content)
+ content = content.render(**template_context)
+
+ html_content = markdown(content)
+ gmi_content = md2gemini(content, links='copy', md_links=True).replace('\r\n', '\n')
+
+ return Page(title, description, excerpt, str(dest).replace('\\', '/'), page_date,
+ content, html_content, gmi_content)
+
+ def describe(self):
+ if self.description is not None:
+ return self.description
+ return self.excerpt
+
+
+class HighlightRenderer(mistune_contrib.highlight.HighlightMixin, mistune.HTMLRenderer):
+ options = {'inlinestyles': False, 'linenos': False}
+
+ def link(self, link, text=None, title=None):
+ if link.startswith('/') and link.endswith('.md'):
+ link = re.sub('.md$', '.html', link)
+ return super(HighlightRenderer, self).link(link, text, title)
+
+
+markdown = mistune.create_markdown(renderer=HighlightRenderer(), plugins=['strikethrough'])
+
+
+def render(site_dir, page: Page):
+ html_dest = (site_dir / 'html' / page.url).with_suffix('.html')
+ html_dest.parent.mkdir(parents=True, exist_ok=True)
+ html_dest.write_text(html_template.render(content=Markup(page.html_content), page=page))
+
+ gmi_dest = (site_dir / 'gmi' / page.url).with_suffix('.gmi')
+ gmi_dest.parent.mkdir(parents=True, exist_ok=True)
+ gmi_dest.write_text(gmi_template.render(content=page.gmi_content, page=page))
+
+
+def parse_front_matter(front_matter: str):
+ lines = front_matter.split('\n')
+ fields = [line.split(': ', 1) for line in lines if len(line.strip()) > 0]
+ return dict((key, value.strip('"')) for key, value in fields)
+
+
+def get_excerpt(body: str):
+ def flatten_ast(node: dict):
+ if not isinstance(node, dict):
+ return str(node)
+ elif node['type'] == 'text':
+ return node['text']
+ else:
+ return ''.join(flatten_ast(child) for child in node['children'])
+
+ ast = mistune.markdown(body, renderer='ast')
+ paragraphs = [node for node in ast if node['type'] == 'paragraph'] + [{'type': 'text', 'text': ''}]
+ first_paragraph = paragraphs[0]
+ excerpt = flatten_ast(first_paragraph)
+ excerpt = re.sub(r'\s+', ' ', excerpt)
+ return excerpt
+
+
+def copy_assets(src: Path, dest: Path):
+ dest_assets = dest / 'assets'
+ if dest_assets.exists():
+ for asset in dest_assets.iterdir():
+ asset.unlink()
+ dest_assets.rmdir()
+ shutil.copytree(src / 'assets', dest_assets)
+
+
+def main():
+ print('building...')
+ env = Environment(
+ loader=FileSystemLoader('_layouts'),
+ autoescape=select_autoescape(['html']),
+ undefined=StrictUndefined,
+ )
+ env.filters['absolute_url'] = lambda x: f"https://www.boringcactus.com/{x}"
+ global html_template
+ html_template = env.get_template('default.html')
+ global gmi_template
+ gmi_template = env.get_template('default.gmi')
+
+ source_dir = Path('.')
+ site_dir = Path('_site')
+ html_site_dir = site_dir / 'html'
+ gmi_site_dir = site_dir / 'gmi'
+
+ html_site_dir.mkdir(parents=True, exist_ok=True)
+ gmi_site_dir.mkdir(parents=True, exist_ok=True)
+
+ copy_assets(source_dir, html_site_dir)
+ copy_assets(source_dir, gmi_site_dir)
+
+ posts = []
+ for post_filename in (source_dir / '_posts').glob('*.md'):
+ print(' -', post_filename.name)
+ page = Page.load(post_filename)
+ render(site_dir, page)
+ posts.append(page)
+
+ posts.sort(key=lambda x: x.date, reverse=True)
+
+ for page_filename in source_dir.glob('*.md'):
+ print(' -', page_filename.name)
+ page = Page.load(page_filename, template_context=dict(posts=posts))
+ render(site_dir, page)
+
+ print(' - feed.xml')
+
+ feed = (source_dir / 'feed.xml').read_text()
+ feed = Template(feed, autoescape=True)
+
+ (html_site_dir / 'feed.xml').write_text(feed.render(
+ root='https://www.boringcactus.com',
+ mime='text/html',
+ ext='html',
+ now=datetime.utcnow(),
+ posts=posts,
+ content_attr='html_content',
+ ))
+ (gmi_site_dir / 'feed.xml').write_text(feed.render(
+ root='gemini://boringcactus.com',
+ mime='text/gemini',
+ ext='gmi',
+ now=datetime.utcnow(),
+ posts=posts,
+ content_attr='gmi_content',
+ ))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..9bd3d0b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+Jinja2
+md2gemini
+mistune
+mistune-contrib
+Pygments