From 700b39593ed6a8bc49b4d4911126ae10ab29f63d Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Mon, 26 Jun 2017 07:51:04 +0100 Subject: [PATCH 01/10] Add table and footnote extensions --- CHANGES.txt | 2 ++ setup.py | 2 +- stone/stone.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index efc7838..09e7d08 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -8,3 +8,5 @@ Version 0.0.1 * Generate HTML from Markdown * jinja2 templating is processed in HTML files +* Installable through setup.py +* Can generate basic template diff --git a/setup.py b/setup.py index 7496fb5..fa71ee5 100755 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ setup(name='stone-site', # Versions should comply with PEP440. For a discussion on single-sourcing - version='0.1a1.dev3', + version='0.1a1.dev4', description='Static site generator', diff --git a/stone/stone.py b/stone/stone.py index 2ad57e3..aacfb37 100755 --- a/stone/stone.py +++ b/stone/stone.py @@ -221,7 +221,8 @@ def generate_site(args): sites = ConfigLoader().load(args.site_root) markdown_renderer = markdown.Markdown( - extensions=['markdown.extensions.meta']) + extensions=['markdown.extensions.meta', 'markdown.extensions.tables', + 'markdown.extensions.footnotes']) for site in sites: env = Environment( loader=FileSystemLoader(site.templates), From ad1d89cda1a0e38b7ebefaf3078c630946d09c0a Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 13:28:28 +0100 Subject: [PATCH 02/10] Split stone into seperate files for classes --- stone/configloader.py | 38 ++++++++ stone/page.py | 89 ++++++++++++++++++ stone/resource.py | 50 ++++++++++ stone/site.py | 63 +++++++++++++ stone/stone.py | 212 +----------------------------------------- 5 files changed, 242 insertions(+), 210 deletions(-) create mode 100644 stone/configloader.py create mode 100644 stone/page.py create mode 100644 stone/resource.py create mode 100644 stone/site.py diff --git a/stone/configloader.py b/stone/configloader.py new file mode 100644 index 0000000..c4e83fe --- /dev/null +++ b/stone/configloader.py @@ -0,0 +1,38 @@ +"""ConfigLoader + +Loader for Stone's site.json +""" +import errno +import json +import os + +from stone.site import Site + + +class ConfigLoader(object): + + site_config_file = "site.json" + + def __init__(self): + pass + + def load(self, path): + """Load site configuration""" + configs = [] + try: + json_data = json.loads( + open(os.path.join(path, self.site_config_file), "r").read()) + except FileNotFoundError as fnf: + if fnf.errno != errno.ENOENT: + raise + else: + print("[ERROR] No path to site config") + return 1 + + try: + for site_data in json_data["sites"]: + configs.append(Site(path, site_data)) + except KeyError: + configs.append(Site(path, site_data)) + + return configs diff --git a/stone/page.py b/stone/page.py new file mode 100644 index 0000000..af95fdf --- /dev/null +++ b/stone/page.py @@ -0,0 +1,89 @@ +"""Page + +Stone's representation of a page +""" +from collections import UserDict +import errno +import os + +from jinja2.exceptions import TemplateNotFound + + +class Page(UserDict): + """Representation of a Page""" + + def __init__(self, + site_root, + source, + target, + page_type=None, + redirects=None): + + self._site_root = site_root + self.data = { + "page_type": page_type, + "redirects": redirects, + "source": source, + "source_path": os.path.abspath(os.path.join(site_root, source)), + "target": target, + "target_path": os.path.abspath(os.path.join(site_root, target)) + } + try: + self.data["href"] = target.split('/')[1] + except IndexError: + self.data["href"] = target + self.data['content'] = open(self.data['source_path'], "r").read() + self.renderer = None + + def __contains__(self, key): + return str(key) in self.data + + def __missing__(self, key): + if str(key) in self.data: + return self.data + else: + raise KeyError(key) + + def __repr__(self): + class_name = type(self).__name__ + return ('{}({!r}, {!r}, {!r}, page_type={!r}, redirects={!r})' + .format(class_name, self._site_root, self.data['source'], + self.data['target'], self.data['page_type'], + self.data['redirects'])) + + def __setitem__(self, key, item): + self.data[str(key)] = item + + def convert_to_template_html(self, md_renderer): + """Convert markdown to templated HTML""" + self.renderer = md_renderer + self.data['content'] = self.renderer.convert(self.data['content']) + for key, value in self.renderer.Meta.items(): + self.data[key] = value[0] + + def render_html(self, environment): + print("Rendering: %s to %s" % (self.data['source_path'], + self.data['target_path'])) + try: + with open(self.data['target_path'], "w") as target_file: + target_file.write( + environment.get_template(self.data['template']).render( + self.data)) + except TemplateNotFound as tnf: + print(tnf) + except KeyError as ke: + if str(ke) == '\'template\'': + print('Missing template, rendering markdown only') + with open(self.data['target_path'], "w") as target_file: + target_file.write( + environment.from_string(self.data['content']).render( + self)) + else: + raise + except FileNotFoundError as fnf: + if fnf.errno == errno.ENOENT: + os.makedirs( + os.path.split(self.data['target_path'])[0], exist_ok=True) + self.render_html(environment) + else: + raise diff --git a/stone/resource.py b/stone/resource.py new file mode 100644 index 0000000..e9f7c26 --- /dev/null +++ b/stone/resource.py @@ -0,0 +1,50 @@ +"""Resource + +Stone's representation for resources like CSS +""" +from collections import UserDict +import errno +import os + + +class Resource(UserDict): + def __init__(self, site_root, source, target, resource_type=None): + self.data = { + "resource_type": resource_type, + "source": source, + "source_path": os.path.abspath(os.path.join(site_root, source)), + "target": target, + "target_path": os.path.abspath(os.path.join(site_root, target)), + "href": target.split('/')[1] + } + with open(self.data["source_path"], "r") as source_file: + self.data["content"] = source_file.read() + + def __contains__(self, key): + return str(key) in self.data + + def __missing__(self, key): + if str(key) in self.data: + return self.data + else: + raise KeyError(key) + + def __repr__(self): + return "Resource(%r, %r)" % (self.data['source'], self.data['target']) + + def __setitem__(self, key, item): + self.data[str(key)] = item + + def render(self): + print("Rendering: %s to %s" % (self.data['source_path'], + self.data['target_path'])) + try: + with open(self.data['target_path'], "w") as target_file: + target_file.write(self.data["content"]) + except FileNotFoundError as fnf: + if fnf.errno == errno.ENOENT: + os.makedirs( + os.path.split(self.data['target_path'])[0], exist_ok=True) + self.render() + else: + raise diff --git a/stone/site.py b/stone/site.py new file mode 100644 index 0000000..de3d650 --- /dev/null +++ b/stone/site.py @@ -0,0 +1,63 @@ +"""Site + +Stone's representation of a website +""" +from collections import UserDict +import os + +from stone.page import Page +from stone.resource import Resource + + +class Site(UserDict): + def __init__(self, root, data): + self.pages = [] + self.index = [] + self.root = root + self.templates = [] + self.resources = [] + self.data = data + self._parse(data) + + def __repr__(self): + return "Site(%r, %r)" % (self.root, self.data) + + def _parse(self, data): + """Load pages to be generated""" + try: + self.pages = [Page(self.root, **page) for page in data["pages"]] + self.templates = [os.path.join(self.root, template) + for template in data["templates"]] + self.resources = [Resource(self.root, **resource) + for resource in data["resources"]] + print(self.resources) + except KeyError as ke: + if ke is 'templates': + print("No temaplates found for %s" % (data["site"])) + + def is_blog(self): + try: + return self.data['type'] == 'blog' + except KeyError as ke: + return False + + def render(self, renderer, environment): + """Render Markdown to HTML and extract YAML metadata""" + for page in self.pages: + page.convert_to_template_html(renderer) + """ + Pages to know their titles, this comes from their YAML metadata + """ + for page in self.pages: + if page.data['page_type'] == "index": + """ + Pass all blog posts to the index page, do not pass other indexes + or page types to the index. + """ + page.data['posts'] = [post for post in self.pages + if post is not page] + page.data['posts'].reverse() + page.render_html(environment) + + for resource in self.resources: + resource.render() diff --git a/stone/stone.py b/stone/stone.py index aacfb37..dd09c65 100755 --- a/stone/stone.py +++ b/stone/stone.py @@ -1,219 +1,11 @@ """Stone library functions?""" -import collections -import errno -import json import os from jinja2 import (Environment, FileSystemLoader, select_autoescape) -from jinja2.exceptions import TemplateNotFound import markdown - -class ConfigLoader(object): - - site_config_file = "site.json" - - def __init__(self): - pass - - def load(self, path): - """Load site configuration""" - configs = [] - try: - json_data = json.loads( - open(os.path.join(path, self.site_config_file), "r").read()) - except FileNotFoundError as fnf: - if fnf.errno != errno.ENOENT: - raise - else: - print("[ERROR] No path to site config") - return 1 - - try: - for site_data in json_data["sites"]: - configs.append(Site(path, site_data)) - except KeyError: - configs.append(Site(path, site_data)) - - return configs - - -class Page(collections.UserDict): - """Representation of a Page""" - - def __init__(self, - site_root, - source, - target, - page_type=None, - redirects=None): - - self._site_root = site_root - self.data = { - "page_type": page_type, - "redirects": redirects, - "source": source, - "source_path": os.path.abspath(os.path.join(site_root, source)), - "target": target, - "target_path": os.path.abspath(os.path.join(site_root, target)) - } - try: - self.data["href"] = target.split('/')[1] - except IndexError: - self.data["href"] = target - self.data['content'] = open(self.data['source_path'], "r").read() - self.renderer = None - - def __contains__(self, key): - return str(key) in self.data - - def __missing__(self, key): - if str(key) in self.data: - return self.data - else: - raise KeyError(key) - - def __repr__(self): - class_name = type(self).__name__ - return ('{}({!r}, {!r}, {!r}, page_type={!r}, redirects={!r})' - .format(class_name, self._site_root, self.data['source'], - self.data['target'], self.data['page_type'], - self.data['redirects'])) - - def __setitem__(self, key, item): - self.data[str(key)] = item - - def convert_to_template_html(self, md_renderer): - """Convert markdown to templated HTML""" - self.renderer = md_renderer - self.data['content'] = self.renderer.convert(self.data['content']) - for key, value in self.renderer.Meta.items(): - self.data[key] = value[0] - - def render_html(self, environment): - print("Rendering: %s to %s" % (self.data['source_path'], - self.data['target_path'])) - try: - with open(self.data['target_path'], "w") as target_file: - target_file.write( - environment.get_template(self.data['template']).render( - self.data)) - except TemplateNotFound as tnf: - print(tnf) - except KeyError as ke: - if str(ke) == '\'template\'': - print('Missing template, rendering markdown only') - with open(self.data['target_path'], "w") as target_file: - target_file.write( - environment.from_string(self.data['content']).render( - self)) - else: - raise - except FileNotFoundError as fnf: - if fnf.errno == errno.ENOENT: - os.makedirs( - os.path.split(self.data['target_path'])[0], exist_ok=True) - self.render_html(environment) - else: - raise - - -class Resource(collections.UserDict): - def __init__(self, site_root, source, target, resource_type=None): - self.data = { - "resource_type": resource_type, - "source": source, - "source_path": os.path.abspath(os.path.join(site_root, source)), - "target": target, - "target_path": os.path.abspath(os.path.join(site_root, target)), - "href": target.split('/')[1] - } - with open(self.data["source_path"], "r") as source_file: - self.data["content"] = source_file.read() - - def __contains__(self, key): - return str(key) in self.data - - def __missing__(self, key): - if str(key) in self.data: - return self.data - else: - raise KeyError(key) - - def __repr__(self): - return "Resource(%r, %r)" % (self.data['source'], self.data['target']) - - def __setitem__(self, key, item): - self.data[str(key)] = item - - def render(self): - print("Rendering: %s to %s" % (self.data['source_path'], - self.data['target_path'])) - try: - with open(self.data['target_path'], "w") as target_file: - target_file.write(self.data["content"]) - except FileNotFoundError as fnf: - if fnf.errno == errno.ENOENT: - os.makedirs( - os.path.split(self.data['target_path'])[0], exist_ok=True) - self.render() - else: - raise - - -class Site(collections.UserDict): - def __init__(self, root, data): - self.pages = [] - self.index = [] - self.root = root - self.templates = [] - self.resources = [] - self.data = data - self._parse(data) - - def __repr__(self): - return "Site(%r, %r)" % (self.root, self.data) - - def _parse(self, data): - """Load pages to be generated""" - try: - self.pages = [Page(self.root, **page) for page in data["pages"]] - self.templates = [os.path.join(self.root, template) - for template in data["templates"]] - self.resources = [Resource(self.root, **resource) - for resource in data["resources"]] - print(self.resources) - except KeyError as ke: - if ke is 'templates': - print("No temaplates found for %s" % (data["site"])) - - def is_blog(self): - try: - return self.data['type'] == 'blog' - except KeyError as ke: - return False - - def render(self, renderer, environment): - """Render Markdown to HTML and extract YAML metadata""" - for page in self.pages: - page.convert_to_template_html(renderer) - """ - Pages to know their titles, this comes from their YAML metadata - """ - for page in self.pages: - if page.data['page_type'] == "index": - """ - Pass all blog posts to the index page, do not pass other indexes - or page types to the index. - """ - page.data['posts'] = [post for post in self.pages - if post is not page] - page.data['posts'].reverse() - page.render_html(environment) - - for resource in self.resources: - resource.render() +from stone.configloader import ConfigLoader def generate_site(args): @@ -222,7 +14,7 @@ def generate_site(args): markdown_renderer = markdown.Markdown( extensions=['markdown.extensions.meta', 'markdown.extensions.tables', - 'markdown.extensions.footnotes']) + 'markdown.extensions.footnotes']) for site in sites: env = Environment( loader=FileSystemLoader(site.templates), From 8febf52a865b84b50dab5fbd952ad0691b9ee96c Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 13:35:18 +0100 Subject: [PATCH 03/10] Clean up argument parsing for stone --- stone/__main__.py | 68 +++++++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/stone/__main__.py b/stone/__main__.py index 3d821f9..0192775 100644 --- a/stone/__main__.py +++ b/stone/__main__.py @@ -4,7 +4,48 @@ import argparse import os -from .stone import generate_site, init_site, new_page +from stone.stone import generate_site, init_site, new_page + + +def add_newpage(parser): + """Add arguments for newpage command""" + subparser = parser.add_parser( + 'newpage', help=('add a new page to site.json and an emtpy file')) + subparser.add_argument( + "source", + type=str, + help='input filename') + subparser.add_argument( + "--target", + type=str, + help='output filename') + subparser.add_argument( + "--page-type", + default="post", + type=str, + help='type of page to generate') + subparser.set_defaults(func=new_page) + + +def add_init(parser): + """Add arguments for init command""" + subparser = parser.add_parser( + 'init', help=('create a template site.json')) + subparser.add_argument( + "--type", default="blog", type=str, help='type of site to generate') + subparser.add_argument( + "--site-name", + type=str, + help='name of the site: example.com', + required=True) + subparser.set_defaults(func=init_site) + + +def add_generate(parser): + """Add arguments for generate command""" + subparser = parser.add_parser( + 'generate', aliases=['gen', 'build'], help=('generate site')) + subparser.set_defaults(func=generate_site) def main(args=None): @@ -18,32 +59,13 @@ def main(args=None): subparsers = parser.add_subparsers(title='commands', help='commands') # stone build - build_parser = subparsers.add_parser( - 'generate', aliases=['gen'], help=('generate site')) - build_parser.set_defaults(func=generate_site) + add_generate(subparsers) # stone init - init_parser = subparsers.add_parser( - 'init', help=('create a template site.json')) - init_parser.add_argument( - "--type", default="blog", type=str, help='type of site to generate') - init_parser.add_argument( - "--site-name", - type=str, - help='name of the site: example.com', - required=True) - init_parser.set_defaults(func=init_site) + add_init(subparsers) # stone newpage - newpage_parser = subparsers.add_parser( - 'newpage', help=('add a new page to site.json and an emtpy file')) - newpage_parser.add_argument( - "--page-type", - default="post", - type=str, - help='type of page to generate') - newpage_parser.set_defaults(func=new_page) - + add_newpage(subparsers) args = parser.parse_args() if not os.path.isdir(args.site_root): From 29ec75a4e1f2d5bf0f2f6e946adbf5caf9239f88 Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 15:10:07 +0100 Subject: [PATCH 04/10] Rename ConfigLoader to Config, load() to read() In preperation for writing configuration files change ConfigLoader to Config, as it will "load" and write. Change load() to read() to fit better with write(). --- stone/{configloader.py => config.py} | 8 ++++---- stone/stone.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) rename stone/{configloader.py => config.py} (88%) diff --git a/stone/configloader.py b/stone/config.py similarity index 88% rename from stone/configloader.py rename to stone/config.py index c4e83fe..f7b0821 100644 --- a/stone/configloader.py +++ b/stone/config.py @@ -1,6 +1,6 @@ -"""ConfigLoader +"""Config -Loader for Stone's site.json +Stone's representation for site.json """ import errno import json @@ -9,14 +9,14 @@ from stone.site import Site -class ConfigLoader(object): +class Config(object): site_config_file = "site.json" def __init__(self): pass - def load(self, path): + def read(self, path): """Load site configuration""" configs = [] try: diff --git a/stone/stone.py b/stone/stone.py index dd09c65..6162bee 100755 --- a/stone/stone.py +++ b/stone/stone.py @@ -5,12 +5,12 @@ from jinja2 import (Environment, FileSystemLoader, select_autoescape) import markdown -from stone.configloader import ConfigLoader +from stone.config import Config def generate_site(args): """Generate site""" - sites = ConfigLoader().load(args.site_root) + sites = Config().read(args.site_root) markdown_renderer = markdown.Markdown( extensions=['markdown.extensions.meta', 'markdown.extensions.tables', @@ -24,7 +24,7 @@ def generate_site(args): def new_page(args): """Add new page to the site""" - sites = ConfigLoader().load(args.site_root) + sites = Config().read(args.site_root) if not hasattr(args, 'site'): print('What site would you like to add a new page to?') count = 0 From 78d0298b274077b1158fd87d8950b9c436f629db Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Mon, 17 Jul 2017 23:14:23 +0100 Subject: [PATCH 05/10] Add newpage command Teach Stone to create a newpage. Add write() to Config, which encodes Sites to JSON using SiteEncoder Add SiteEncoder which will correctly encode Site into JSON. SiteEncoder will call PageEncoder().default when it encounters a Page. PageEncoder will return Page.to_entry Add to_entry to Page, return a dictionary with data Config is interested. Teach Site add_page() and improve the typing information it provides. Also implment __contains__() Overhaul Page's __init__(). Make page store more generic informantio than it was previously able to store. Page is meant to be a dictionary so implement __delitem__, __getitem__, __iter__, __len__, clear, get and set. --- requirements.txt | 3 +- stone/config.py | 16 +++++- stone/page.py | 136 ++++++++++++++++++++++++++++++++-------------- stone/resource.py | 3 + stone/site.py | 112 +++++++++++++++++++++++++++++++------- stone/stone.py | 72 ++++++++++++++++++------ 6 files changed, 262 insertions(+), 80 deletions(-) diff --git a/requirements.txt b/requirements.txt index 57ef65b..90b5325 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +css_html_js_minify jinja2 Markdown -css_html_js_minify +unidecode diff --git a/stone/config.py b/stone/config.py index f7b0821..2d91e83 100644 --- a/stone/config.py +++ b/stone/config.py @@ -5,11 +5,13 @@ import errno import json import os +import sys -from stone.site import Site +from stone.site import Site, SiteEncoder class Config(object): + """Loader site.json""" site_config_file = "site.json" @@ -30,9 +32,21 @@ def read(self, path): return 1 try: + if json_data["version"] != 1: + print( + "This is an older site.json and it doesn't checkout", + file=sys.stderr) + exit(1) for site_data in json_data["sites"]: configs.append(Site(path, site_data)) except KeyError: configs.append(Site(path, site_data)) return configs + + def write(self, path, sites): + """Serialize a site to JSON""" + config = {'version': 1} + config['sites'] = sites + with open(os.path.join(path, self.site_config_file), "w") as cfg_file: + cfg_file.write(json.dumps(config, cls=SiteEncoder, indent=4)) diff --git a/stone/page.py b/stone/page.py index af95fdf..9f66bc6 100644 --- a/stone/page.py +++ b/stone/page.py @@ -4,30 +4,54 @@ """ from collections import UserDict import errno +from json import JSONEncoder import os +import sys +from typing import Any, Dict from jinja2.exceptions import TemplateNotFound -class Page(UserDict): +class PageEncoder(JSONEncoder): + """JSON encoder for Page""" + + def default(self, o): + if isinstance(o, Page): + return o.to_entry() + + # When not a page, call the JSONEncoder. It will call the correct fail. + return JSONEncoder.default(self, o) + + +class Page(UserDict): # pylint: disable=too-many-ancestors """Representation of a Page""" - def __init__(self, - site_root, - source, - target, - page_type=None, - redirects=None): - - self._site_root = site_root - self.data = { - "page_type": page_type, - "redirects": redirects, - "source": source, - "source_path": os.path.abspath(os.path.join(site_root, source)), - "target": target, - "target_path": os.path.abspath(os.path.join(site_root, target)) - } + _site = None + data: Dict[str, str] = {} + + def __init__(self, site, source: str, target: str, + data: Dict=None) -> None: + super().__init__() + self.data = {} + self._site = site + + def get(key, dic): + """Get a dictionary key if it exists""" + return dic[key] if key in dic else None + + try: + for k in data.keys(): + self.data[k] = get(k, data) + except AttributeError: + pass + + self.data["source"] = source + self.data["target"] = target + self.data["source_path"] = os.path.abspath( + os.path.join(self._site.root, site['source'], source)) + self.data["target"] = target + self.data["target_path"] = os.path.abspath( + os.path.join(self._site.root, site['target'], target)) try: self.data["href"] = target.split('/')[1] except IndexError: @@ -36,48 +60,66 @@ def __init__(self, self.renderer = None def __contains__(self, key): - return str(key) in self.data + return key in self.data + + def __delitem__(self, key): + del self.data[key] - def __missing__(self, key): - if str(key) in self.data: - return self.data - else: - raise KeyError(key) + def __getitem__(self, key): + return self.data[key] + + def __iter__(self): + self.data.__iter__() + + def __len__(self): + return len(self.data) def __repr__(self): class_name = type(self).__name__ - return ('{}({!r}, {!r}, {!r}, page_type={!r}, redirects={!r})' - .format(class_name, self._site_root, self.data['source'], - self.data['target'], self.data['page_type'], - self.data['redirects'])) + return "{}({}, {})".format(class_name, self['source'], self['target']) + + def __str__(self): + return str(self.to_entry()) def __setitem__(self, key, item): - self.data[str(key)] = item + self.data[key] = item + + def clear(self): + self.data = {} def convert_to_template_html(self, md_renderer): """Convert markdown to templated HTML""" self.renderer = md_renderer - self.data['content'] = self.renderer.convert(self.data['content']) + self['content'] = self.renderer.convert(self['content']) for key, value in self.renderer.Meta.items(): - self.data[key] = value[0] + self[key] = value[0] + + def get(self, key, default=None): + try: + return self.data[key] + except KeyError: + return default def render_html(self, environment): - print("Rendering: %s to %s" % (self.data['source_path'], - self.data['target_path'])) + """Render the page to html""" + print("Rendering: %s to %s" % (self['source_path'], + self['target_path'])) try: - with open(self.data['target_path'], "w") as target_file: + with open(self['target_path'], "w") as target_file: target_file.write( - environment.get_template(self.data['template']).render( + environment.get_template(self['template']).render( self.data)) except TemplateNotFound as tnf: print(tnf) - except KeyError as ke: - if str(ke) == '\'template\'': - print('Missing template, rendering markdown only') - with open(self.data['target_path'], "w") as target_file: + except KeyError as key_error: + if str(key_error) == '\'template\'': + print( + 'Missing template, rendering markdown only', + file=sys.stderr) + with open(self['target_path'], "w") as target_file: target_file.write( - environment.from_string(self.data['content']).render( - self)) + environment.from_string(self['content']).render( + self.data)) else: raise except FileNotFoundError as fnf: @@ -87,3 +129,17 @@ def render_html(self, environment): self.render_html(environment) else: raise + + def to_entry(self) -> Dict[str, Any]: + """"Convert Page into serialised json for site.json""" + + def get(key): + """return a dictionary item or None""" + return self[key] if key in self else None + + items = ['source', 'target', 'page_type'] + entry = {} + for item in items: + if get(item) is not None: + entry[item] = self[item] + return entry diff --git a/stone/resource.py b/stone/resource.py index e9f7c26..68ca08a 100644 --- a/stone/resource.py +++ b/stone/resource.py @@ -2,13 +2,16 @@ Stone's representation for resources like CSS """ + from collections import UserDict import errno import os class Resource(UserDict): + """Resource: Stones representation for resources like CSS""" def __init__(self, site_root, source, target, resource_type=None): + super().__init__() self.data = { "resource_type": resource_type, "source": source, diff --git a/stone/site.py b/stone/site.py index de3d650..75ee17b 100644 --- a/stone/site.py +++ b/stone/site.py @@ -2,43 +2,112 @@ Stone's representation of a website """ +# -*- coding: utf-8 -*- + from collections import UserDict +from json import JSONEncoder import os +from typing import Dict, List -from stone.page import Page +from stone.page import Page, PageEncoder from stone.resource import Resource -class Site(UserDict): - def __init__(self, root, data): - self.pages = [] - self.index = [] +class SiteEncoder(JSONEncoder): + """JSON encoder for Site""" + + def default(self, o): + if isinstance(o, Site): + items = ['site', 'type', 'source', 'target', 'templates'] + result = {} + for item in items: + try: + result[item] = o[item] + except KeyError: + pass + result['pages'] = o.pages + return result + if isinstance(o, Page): + return PageEncoder().default(o) + + # When not a page, call the JSONEncoder. It will call the correct fail. + return JSONEncoder.default(self, o) + + +class Site(UserDict): # pylint: disable=too-many-ancestors + """Representation of a Site""" + + root: str + pages: List[Page] + templates: List[str] + resources: List[str] + data: Dict[str, str] + + def __init__(self, root: str, data: Dict) -> None: + super().__init__() self.root = root + self.data = data + self.pages = [] self.templates = [] self.resources = [] - self.data = data self._parse(data) + def __contains__(self, key): + return str(key) in self.data + def __repr__(self): return "Site(%r, %r)" % (self.root, self.data) + def __str__(self): + """Return the stringified version of Site + + Return a selective dictionary of Site as a string + """ + items = ['site', 'type', 'source', 'target', 'templates'] + result = {} + for item in items: + try: + result[item] = self[item] + except KeyError: + pass + result['pages'] = self.pages + return str(result) + + def __setitem__(self, key, item): + self.data[str(key)] = item + def _parse(self, data): """Load pages to be generated""" try: - self.pages = [Page(self.root, **page) for page in data["pages"]] - self.templates = [os.path.join(self.root, template) - for template in data["templates"]] - self.resources = [Resource(self.root, **resource) - for resource in data["resources"]] - print(self.resources) - except KeyError as ke: - if ke is 'templates': + for page_data in data['pages']: + self.pages.append( + Page( + self, + page_data.pop('source'), + page_data.pop('target'), + data=page_data)) + + self.templates = [ + os.path.join(self.root, template) + for template in data["templates"] + ] + self.resources = [ + Resource(self.root, **resource) + for resource in data["resources"] + ] + except KeyError as key_error: + if key_error == 'templates': print("No temaplates found for %s" % (data["site"])) + def add_page(self, page): + """Add supplied page to site""" + self.pages.append(page) + def is_blog(self): + """Returns if the site is a blog or not""" try: return self.data['type'] == 'blog' - except KeyError as ke: + except KeyError: return False def render(self, renderer, environment): @@ -49,14 +118,15 @@ def render(self, renderer, environment): Pages to know their titles, this comes from their YAML metadata """ for page in self.pages: - if page.data['page_type'] == "index": + if page['page_type'] == "index": """ - Pass all blog posts to the index page, do not pass other indexes - or page types to the index. + Pass all blog posts to the index page, do not pass other + indexes or page types to the index. """ - page.data['posts'] = [post for post in self.pages - if post is not page] - page.data['posts'].reverse() + page['posts'] = [ + post for post in self.pages if post is not page + ] + page['posts'].reverse() page.render_html(environment) for resource in self.resources: diff --git a/stone/stone.py b/stone/stone.py index 6162bee..4c3be63 100755 --- a/stone/stone.py +++ b/stone/stone.py @@ -1,20 +1,24 @@ """Stone library functions?""" +# -*- coding: utf-8 -*- import os -from jinja2 import (Environment, FileSystemLoader, select_autoescape) -import markdown +from jinja2 import select_autoescape, Environment, FileSystemLoader +from markdown import Markdown from stone.config import Config +from stone.page import Page +from stone.site import Site def generate_site(args): """Generate site""" sites = Config().read(args.site_root) - markdown_renderer = markdown.Markdown( - extensions=['markdown.extensions.meta', 'markdown.extensions.tables', - 'markdown.extensions.footnotes']) + markdown_renderer = Markdown(extensions=[ + 'markdown.extensions.meta', 'markdown.extensions.tables', + 'markdown.extensions.footnotes' + ]) for site in sites: env = Environment( loader=FileSystemLoader(site.templates), @@ -35,28 +39,62 @@ def new_page(args): choice = input() if not int(choice) < len(sites): print("[ERROR] %s is not a valid selection" % choice) - return 1 + + site = sites[int(choice)] + if site: + page = create_page(site, args.source, args.target) + page['page_type'] = args.page_type + site.add_page(page) + Config().write(args.site_root, sites) + + +def create_page(site: Site, source: str, target: str) -> Page: + """Create a Page() and file on disk""" + init_content = '# Hello, World!' + + try: + os.mkdir(os.path.join('{}/{}'.format(site.root, site['source']))) + except FileExistsError: + pass + + file_path = '{}/{}/{}'.format(site.root, site['source'], source) + file = open(file_path, 'w') + file.write(init_content) + file.close() + + if target is None: + # Attempt to sanitize the filename from source for our output.html + from unidecode import unidecode + target = unidecode(source) + target = target.lower().replace(r' ', '-') + target = '{}.html'.format(target.split('.')[0]) + + page = Page(site, source, target) + return page def init_site(args): + """Creata a new site from template""" template_sites = '{{"sites":[{!s}]}}' - template_site = '{{"site":"{!s}","pages":[{!s}],"type":"{!s}","templates":"[{!s}]"}}' - template_page = '{{"page_type":"{!s}","source":"{!s}","target":"{!s}","redirects":"{!s}"}}' + template_site = ('{{"site":"{!s}",' + '"pages":[{!s}],"type":"{!s}","templates":"[{!s}]"}}') + template_page = ('{{"page_type":"{!s}","source":"{!s}","target":"{!s}",' + '"redirects":"{!s}"}}') init_content = '# Hello, World!' init_index = template_page.format('page', 'source/index.md', 'target/index.html', '') - init_site = template_site.format(args.site_name, init_index, 'page', '') - init_sites = template_sites.format(init_site) + site = template_site.format(args.site_name, init_index, 'page', '') + sites = template_sites.format(site) - f = open('{}/site.json'.format(args.site_root), 'w') - f.write(init_sites) - f.close() + file = open('{}/site.json'.format(args.site_root), 'w') + file.write(sites) + file.close() try: os.mkdir(os.path.join('{}/source'.format(args.site_root))) - except FileExistsError as fef: + except FileExistsError: pass - f = open('{}/source/index.md'.format(args.site_root), 'w') - f.write(init_content) - f.close() + file = open('{}/source/index.md'.format(args.site_root), 'w') + file.write(init_content) + file.close() From 6383a0993ff8ee9f0f111eab4e9c805709269ec8 Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 17:55:04 +0100 Subject: [PATCH 06/10] Update stone init to use Config Reimplement init_site() to use Config.write instead of hand crafting site.json. Produce both a blog and single page site. Rename create_page() to create_add_page(). It no longer returns a page but adds it directly to the passed site. Update new_page() to use the new function. Move the announcing which page is being render to where from Page.render_html to Site.render. This stops the double printing of this line when the target file doesn't exist. --- stone/page.py | 4 ---- stone/site.py | 6 ++--- stone/stone.py | 60 ++++++++++++++++++++++++-------------------------- 3 files changed, 32 insertions(+), 38 deletions(-) diff --git a/stone/page.py b/stone/page.py index 9f66bc6..0dedc25 100644 --- a/stone/page.py +++ b/stone/page.py @@ -102,15 +102,11 @@ def get(self, key, default=None): def render_html(self, environment): """Render the page to html""" - print("Rendering: %s to %s" % (self['source_path'], - self['target_path'])) try: with open(self['target_path'], "w") as target_file: target_file.write( environment.get_template(self['template']).render( self.data)) - except TemplateNotFound as tnf: - print(tnf) except KeyError as key_error: if str(key_error) == '\'template\'': print( diff --git a/stone/site.py b/stone/site.py index 75ee17b..a4e988f 100644 --- a/stone/site.py +++ b/stone/site.py @@ -113,10 +113,8 @@ def is_blog(self): def render(self, renderer, environment): """Render Markdown to HTML and extract YAML metadata""" for page in self.pages: + # Pages require initial parsing to read their YAML metadata page.convert_to_template_html(renderer) - """ - Pages to know their titles, this comes from their YAML metadata - """ for page in self.pages: if page['page_type'] == "index": """ @@ -127,6 +125,8 @@ def render(self, renderer, environment): post for post in self.pages if post is not page ] page['posts'].reverse() + print("Rendering: {} to {}".format(page['source_path'], + page['target_path'])) page.render_html(environment) for resource in self.resources: diff --git a/stone/stone.py b/stone/stone.py index 4c3be63..e75f9b4 100755 --- a/stone/stone.py +++ b/stone/stone.py @@ -33,7 +33,7 @@ def new_page(args): print('What site would you like to add a new page to?') count = 0 for site in sites: - print("%i - %s" % (count, site.data['site'])) + print("%i - %s" % (count, site['site'])) count += 1 choice = input() @@ -42,15 +42,17 @@ def new_page(args): site = sites[int(choice)] if site: - page = create_page(site, args.source, args.target) - page['page_type'] = args.page_type - site.add_page(page) + create_add_page(site, args.source, args.target, + data={'page_type': args.page_type}) Config().write(args.site_root, sites) -def create_page(site: Site, source: str, target: str) -> Page: +def create_add_page(site: Site, source: str, target: str, data=None, + content=None): """Create a Page() and file on disk""" init_content = '# Hello, World!' + if content is None and not isinstance(str, content): + content = init_content try: os.mkdir(os.path.join('{}/{}'.format(site.root, site['source']))) @@ -59,7 +61,7 @@ def create_page(site: Site, source: str, target: str) -> Page: file_path = '{}/{}/{}'.format(site.root, site['source'], source) file = open(file_path, 'w') - file.write(init_content) + file.write(content) file.close() if target is None: @@ -69,32 +71,28 @@ def create_page(site: Site, source: str, target: str) -> Page: target = target.lower().replace(r' ', '-') target = '{}.html'.format(target.split('.')[0]) - page = Page(site, source, target) - return page + site.add_page(Page(site, source, target, data)) def init_site(args): """Creata a new site from template""" - template_sites = '{{"sites":[{!s}]}}' - template_site = ('{{"site":"{!s}",' - '"pages":[{!s}],"type":"{!s}","templates":"[{!s}]"}}') - template_page = ('{{"page_type":"{!s}","source":"{!s}","target":"{!s}",' - '"redirects":"{!s}"}}') - init_content = '# Hello, World!' - init_index = template_page.format('page', 'source/index.md', - 'target/index.html', '') - site = template_site.format(args.site_name, init_index, 'page', '') - sites = template_sites.format(site) - - file = open('{}/site.json'.format(args.site_root), 'w') - file.write(sites) - file.close() - - try: - os.mkdir(os.path.join('{}/source'.format(args.site_root))) - except FileExistsError: - pass - - file = open('{}/source/index.md'.format(args.site_root), 'w') - file.write(init_content) - file.close() + index_content = ''' +{% for post in posts %} + +{% endfor %} +''' + init_content = 'title: Hello, World!\n\n# Hello, World!' + + site = Site(args.site_root, {'site': args.site_name, 'source': 'source', + 'target': 'target'}) + if args.type == 'blog': + create_add_page(site, 'index.md', 'index.html', + content=index_content, data={'page_type': 'index'}) + create_add_page(site, 'example.md', 'example.html', + content=init_content, data={'page_type': 'post'}) + else: + create_add_page(site, 'index.md', 'index.html', content=init_content) + + Config().write(args.site_root, [site]) From f6759028b162fad0e29e3e90279c0f456d5eead3 Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 21:21:39 +0100 Subject: [PATCH 07/10] Update example Change the example to the blog generated by `stone init` --- example/README.md | 1 - example/site.json | 30 +++++++++++++++++++----------- example/source/example.md | 3 +++ example/source/index.md | 6 ++++++ example/templates/base.html | 26 -------------------------- 5 files changed, 28 insertions(+), 38 deletions(-) delete mode 120000 example/README.md create mode 100644 example/source/example.md create mode 100644 example/source/index.md delete mode 100644 example/templates/base.html diff --git a/example/README.md b/example/README.md deleted file mode 120000 index 32d46ee..0000000 --- a/example/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/example/site.json b/example/site.json index 98f3086..3fa6bf9 100644 --- a/example/site.json +++ b/example/site.json @@ -1,14 +1,22 @@ { - "sites": [ - { - "site": "example.com", - "pages": [ + "version": 1, + "sites": [ { - "source": "README.md", - "target": "README.html" + "site": "example.com", + "source": "source", + "target": "target", + "pages": [ + { + "source": "index.md", + "target": "index.html", + "page_type": "index" + }, + { + "source": "example.md", + "target": "example.html", + "page_type": "post" + } + ] } - ], - "templates": ["templates"] - } - ] -} + ] +} \ No newline at end of file diff --git a/example/source/example.md b/example/source/example.md new file mode 100644 index 0000000..3b36829 --- /dev/null +++ b/example/source/example.md @@ -0,0 +1,3 @@ +title: Hello, World! + +# Hello, World! \ No newline at end of file diff --git a/example/source/index.md b/example/source/index.md new file mode 100644 index 0000000..675e35a --- /dev/null +++ b/example/source/index.md @@ -0,0 +1,6 @@ + +{% for post in posts %} + +{% endfor %} diff --git a/example/templates/base.html b/example/templates/base.html deleted file mode 100644 index 4a9a1c3..0000000 --- a/example/templates/base.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - {{ title }} - - - -
- {{ content|safe }} -
- - From 59bf2b24332623d64341492e01b078f62c628731 Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 22:11:23 +0100 Subject: [PATCH 08/10] Update README and add site.json docs --- README.md | 52 ++++++++++++++++++++++----------------- example/source/example.md | 3 ++- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 623a2d4..87b92f5 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# stone +# Stone Yet another static website generator. -Used by half.systems +Used by (www|blog).half.systems # Installation @@ -19,51 +19,57 @@ To get started with `stone`: stone example_site init --site-name 'example.com' # Generate site stone example_site generate + # Add a new page + stone example_site newpage --name "About Us" ## Folder Structure -Site projects can be structured as you wish. +Stone is designed to generate the subdomains of half.systems. The following is +the layout of the sites: -The layout which stone was developed along side is: -* root - * blog - * main - * templates - * templated HTML for blog and main - * site.json + . + ├── blog + │   └── ... + ├── main + │   └── ... + ├── site.json + └── templates + └── ... -As `site.json` is explicit about the location of templates and files, the -structure is flexible. You could locate separate template folder inside each -site or have one giant mess in your project root. + +[`site.json` is very flexiable](docs/site-json.md) about the location of +templates and files. As such your not constrained to any particular layout for +your site. You could have separate template folders inside each site or have +one giant mess in your project root. ## Pages -The source markdown files should consist of simple markdown with a YAML header +Pages are Markdown files with some optional YAML metadata that describe the attributes of the generated page including the page title and the template it uses. For example: ``` +--- template: base.html -title: TEST +title: Hello, World # This is a header -Here is so lovely content. +Here is some lovely content. ``` -There are additional attributes: - -* date - Adds the date the page was create to the page metadata. This is - currently used when generating indexes for blogs. Format YYYY-MM-DD +Stone makes all metadata available to page templates. Any data templates may use +can be embedded into a page. For exampled the data, an authors name and email, +etc. ## Templates -Templates support **jinja2**, an example: +Templates are HTML pages with **[jinja2](http://jinja.pocoo.org)** markup. `base.html`: @@ -87,8 +93,8 @@ Templates support **jinja2**, an example: ## Generating -To generate a particular site invoke `stone` with the location of the -project's root folder. +To generate a particular site invoke `stone` with the location of the project's +root folder. ``` stone root_folder generate diff --git a/example/source/example.md b/example/source/example.md index 3b36829..c0f51b0 100644 --- a/example/source/example.md +++ b/example/source/example.md @@ -1,3 +1,4 @@ +--- title: Hello, World! -# Hello, World! \ No newline at end of file +# Hello, World! From 5d9e809a7237708c5326723de5a7b0cc091ee6cd Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 22:14:46 +0100 Subject: [PATCH 09/10] Update CHANGES.txt --- CHANGES.txt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 09e7d08..fa7d4f0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,10 +1,14 @@ -Stone Changelog -=============== +# Stone Changelog Here are the release notes for each version of Stone. -Version 0.0.1 -------------- +## Version 0.1 - WIP + +* Add init and newpage commands +* Add versioned site.json +* Add support for footnotes + +## Version 0.0.1 * Generate HTML from Markdown * jinja2 templating is processed in HTML files From 0a7cc9dc676be3502a4c73b600033e1fa2f1c98f Mon Sep 17 00:00:00 2001 From: Sean Jones Date: Sun, 23 Jul 2017 22:17:55 +0100 Subject: [PATCH 10/10] Update setup.py and bump version --- setup.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index fa71ee5..20eeda0 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,6 @@ """Setup script for Stone -See: -https://github.com/NeuralSandwich/stone +See: https://github.com/NeuralSandwich/stone """ # To use a consistent encoding @@ -21,8 +20,8 @@ setup(name='stone-site', - # Versions should comply with PEP440. For a discussion on single-sourcing - version='0.1a1.dev4', + # Versions should comply with PEP440. For a discussion on single-sourcing + version='0.1a2', description='Static site generator', @@ -59,7 +58,8 @@ # List run-time dependencies here. # https://packaging.python.org/en/latest/requirements.html - install_requires=['jinja2', 'Markdown'], + install_requires=['css_html_js_minify', 'jinja2', 'Markdown', + 'unidecode'], extras_require={ 'dev': ['check-manifest'], 'test': ['coverage'],