├── .gitignore
├── LICENSE
├── MANIFEST.in
├── README.md
├── lettersmith
├── __init__.py
├── absolutize.py
├── archive.py
├── blog.py
├── cli
│ └── scaffold.py
├── data.py
├── date.py
├── doc.py
├── docs.py
├── edge.py
├── file.py
├── files.py
├── func.py
├── html.py
├── io.py
├── jinjatools.py
├── lens.py
├── markdowntools.py
├── package_data
│ ├── scaffold
│ │ └── blog
│ │ │ ├── build.py
│ │ │ ├── data
│ │ │ └── .empty
│ │ │ ├── page
│ │ │ ├── .empty
│ │ │ ├── About.md
│ │ │ ├── Page A.md
│ │ │ ├── Page B.md
│ │ │ └── index.md
│ │ │ ├── post
│ │ │ └── Example Post.md
│ │ │ ├── static
│ │ │ └── .empty
│ │ │ └── template
│ │ │ ├── _base.html
│ │ │ ├── archive.html
│ │ │ ├── index.html
│ │ │ ├── page.html
│ │ │ └── post.html
│ └── template
│ │ ├── rss.xml
│ │ └── sitemap.xml
├── path.py
├── permalink.py
├── query.py
├── rss.py
├── sitemap.py
├── stringtools.py
├── stub.py
├── taxonomy.py
├── util.py
├── wikidoc.py
├── wikimarkup.py
└── write.py
├── setup.py
└── test
├── __init__.py
├── package_data
└── fixtures
│ ├── Bare doc.md
│ ├── Doc with meta.md
│ ├── doc.json
│ └── doc.yaml
├── scripts
└── generate_fixtures.py
├── test_doc.py
├── test_func.py
├── test_html.py
├── test_lens.py
├── test_query.py
├── test_stringtools.py
├── test_util.py
└── test_wikilink.py
/.gitignore:
--------------------------------------------------------------------------------
1 | **.pyc
2 | **__pycache__
3 | **.egg-info
4 | **.DS_Store
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Gordon Brander
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include lettersmith/package_data *
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lettersmith
2 |
3 | A set of tools for static site generation. It comes with a static site generator bundled in. You can also use it as a library to build your own custom static site generator.
4 |
5 | I built it for myself, because I found other solutions to be pretty baroque and difficult to customize. Right now, it's a simple set of fairly stable tools for personal use. I might package it up later.
6 |
7 | ## Installing
8 |
9 | Lettersmith requires Python 3.6+, and a version of pip compatible with Python 3.
10 |
11 | ```bash
12 | git clone https://github.com/gordonbrander/lettersmith_py
13 | cd lettersmith_py
14 | pip3 install -e .
15 | ```
16 |
17 | ## lettersmith_scaffold
18 |
19 | You can easily scaffold a site using `lettersmith_scaffold`.
20 |
21 | ```bash
22 | lettersmith_scaffold ./blog --type blog
23 | ```
24 |
25 | This will stub out a directory structure and a build script for a typical blogging setup. You can customize the build script from there.
26 |
27 |
28 | ## What it does
29 |
30 | Lettersmith comes bundled with a static site generator, but it's really just a library of tools for transforming text. You can use these tools to create your own custom static site generators, build tools, project scaffolders, ebook generators, or wikis — whatever you like.
31 |
32 | Lettersmith loads text files as Python namedtuples, so a markdown file like this:
33 |
34 | ```markdown
35 | ---
36 | title: "My post"
37 | created: 2018-01-17
38 | ---
39 |
40 | Some content
41 | ```
42 |
43 | Becomes this:
44 |
45 | ```python
46 | Doc(
47 | id_path='path/to/post.md',
48 | output_path='path/to/post.md',
49 | input_path='path/to/post.md',
50 | created=datetime.datetime(2018, 1, 17, 0, 0),
51 | modified=datetime.datetime(2018, 1, 17, 0, 0),
52 | title='My post',
53 | content='Some content',
54 | meta={
55 | "title": "My post",
56 | "date": "2018-01-17"
57 | },
58 | template=""
59 | )
60 | ```
61 |
62 |
63 | ## Plugins
64 |
65 | Plugins are just functions that transform doc namedtuples.
66 |
67 | To transform many files, you can load them into an iterable, then use list comprehensions, generator expressions, and map, filter, reduce:
68 |
69 | ```python
70 | # Get all markdown docs under source/
71 | posts = docs.find("posts/*.md")
72 | # Transform them with your function.
73 | posts = my_plugin(posts)
74 | ```
75 |
76 | To write a plugin, all you need to do is define a generator function that takes an iterator of docs and yields transformed docs.
77 |
78 | ```python
79 | def my_plugin(docs)
80 | for doc in docs:
81 | yield do_something(doc)
82 | ```
83 |
84 | You can pipe docs through many transforming functions using `pipe`.
85 |
86 | ```python
87 | posts = pipe(
88 | docs.find("source/*.md"),
89 | markdown.content,
90 | my_plugin,
91 | my_other_plugin
92 | )
93 | ```
94 |
95 | Which is equivalent to:
96 |
97 | ```python
98 | posts = my_other_plugin(my_plugin(markdown.content(docs.find("source/*.md"))))
99 | ```
100 |
101 | When you're done transforming things, you can pass the iterable to `write`, which takes care of writing out the files to an output directory.
102 |
103 | ```python
104 | write(posts, directory="public")
105 | ```
106 |
107 | That's it!
108 |
109 | Check out [blog/build.py](/lettersmith/package_data/scaffold/blog/build.py) for an example of a build script that uses some of the built-in plugins to create a typical blogging setup.
110 |
111 | Lettersmith comes with a swiss army knife of helpful tools for things like Markdown, templates, drafts, tags, wikilinks, and more — and if you see something missing it's easy to write your own functions.
--------------------------------------------------------------------------------
/lettersmith/__init__.py:
--------------------------------------------------------------------------------
1 | from lettersmith import absolutize
2 | from lettersmith import archive
3 | from lettersmith import blog
4 | from lettersmith import data
5 | from lettersmith import docs
6 | from lettersmith import files
7 | from lettersmith import html
8 | from lettersmith import jinjatools
9 | from lettersmith import markdowntools
10 | from lettersmith import permalink
11 | from lettersmith import query
12 | from lettersmith import rss
13 | from lettersmith import sitemap
14 | from lettersmith import stub
15 | from lettersmith import taxonomy
16 | from lettersmith import wikidoc
17 | from lettersmith.write import write
18 | from lettersmith.func import rest, pipe, compose, thrush
19 | from itertools import chain
--------------------------------------------------------------------------------
/lettersmith/absolutize.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for making relative URLs absolute in doc content.
3 | """
4 | import re
5 | from lettersmith.docs import renderer
6 | from lettersmith.func import composable
7 | from lettersmith import path as pathtools
8 |
9 |
10 | URL_ATTR = r"""(src|href)=["'](.*?)["']"""
11 |
12 |
13 | def absolutize(base_url):
14 | """
15 | Absolutize URLs in content. Replaces any relative URLs in content
16 | that start with `/` and instead starts them with `base_url`.
17 |
18 | URLS are found by matching against `href=` and `src=`.
19 | """
20 | def render_inner_match(match):
21 | attr = match.group(1)
22 | value = match.group(2)
23 | url = pathtools.qualify_url(value, base_url)
24 | return '{attr}="{url}"'.format(attr=attr, url=url)
25 |
26 | @renderer
27 | def render(content):
28 | """
29 | Absolutize URLs in doc content fields.
30 | """
31 | return re.sub(URL_ATTR, render_inner_match, content)
32 | return render
--------------------------------------------------------------------------------
/lettersmith/archive.py:
--------------------------------------------------------------------------------
1 | """
2 | Generate an archive page.
3 | """
4 | from lettersmith import doc as Doc
5 | from lettersmith import stub as Stub
6 | from lettersmith.func import composable
7 |
8 |
9 | @composable
10 | def archive(
11 | docs,
12 | output_path,
13 | title="Archive",
14 | template="archive.html"
15 | ):
16 | """
17 | Generate an archive doc for a list of docs.
18 | """
19 | archive = tuple(Stub.stubs(docs))
20 | return Doc.create(
21 | id_path=output_path,
22 | output_path=output_path,
23 | title=title,
24 | content="",
25 | template=template,
26 | meta={"archive": archive}
27 | )
--------------------------------------------------------------------------------
/lettersmith/blog.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for blogging
3 | """
4 | from lettersmith.func import compose
5 | from lettersmith import wikidoc
6 | from lettersmith import absolutize
7 | from lettersmith import permalink
8 | from lettersmith import docs as Docs
9 |
10 |
11 | def markdown_doc(base_url):
12 | """
13 | Handle typical transformations for a generic markdown doc.
14 |
15 | - Markdown
16 | - Wikilinks
17 | - Transclusions
18 | - Absolutizes post links
19 | - Changes file extension to .html
20 | - Sets template in prep for Jinja rendering later
21 | """
22 | return compose(
23 | absolutize.absolutize(base_url),
24 | wikidoc.content_markdown(base_url),
25 | Docs.autotemplate,
26 | Docs.uplift_frontmatter
27 | )
28 |
29 |
30 | def markdown_page(base_url, relative_to="."):
31 | """
32 | Performs typical transformations for a page.
33 |
34 | - Sets nice page permalink
35 | - Markdown
36 | - Wikilinks
37 | - Transclusions
38 | - Absolutizes post links
39 | - Changes file extension to .html
40 | - Sets template in prep for Jinja rendering later
41 | """
42 | return compose(
43 | markdown_doc(base_url),
44 | permalink.rel_page_permalink(relative_to)
45 | )
46 |
47 |
48 | def markdown_post(base_url):
49 | """
50 | Performs typical transformations for a blog post.
51 |
52 | - Sets nice date-based permalink
53 | - Markdown
54 | - Wikilinks
55 | - Transclusions
56 | - Absolutizes post links
57 | - Changes file extension to .html
58 | - Sets template in prep for Jinja rendering later
59 | """
60 | return compose(
61 | markdown_doc(base_url),
62 | permalink.post_permalink
63 | )
64 |
65 |
66 | def html_doc(base_url):
67 | """
68 | Handle typical transformations for a generic html doc.
69 |
70 | - Wrap non-HTML lines with paragraph tags.
71 | - Wikilinks
72 | - Transclusions
73 | - Absolutizes post links
74 | - Changes file extension to .html
75 | - Sets template in prep for Jinja rendering later
76 | """
77 | return compose(
78 | absolutize.absolutize(base_url),
79 | wikidoc.content_html(base_url),
80 | Docs.autotemplate,
81 | Docs.uplift_frontmatter
82 | )
83 |
84 |
85 | def html_page(base_url, relative_to="."):
86 | """
87 | Performs typical transformations for a page.
88 |
89 | - Sets nice page permalink
90 | - Wrap non-HTML lines with paragraph tags.
91 | - Wikilinks
92 | - Transclusions
93 | - Absolutizes post links
94 | - Changes file extension to .html
95 | - Sets template in prep for Jinja rendering later
96 | """
97 | return compose(
98 | html_doc(base_url),
99 | permalink.rel_page_permalink(relative_to)
100 | )
101 |
102 |
103 | def html_post(base_url):
104 | """
105 | Performs typical transformations for a blog post.
106 |
107 | - Sets nice date-based permalink
108 | - Wrap non-HTML lines with paragraph tags.
109 | - Wikilinks
110 | - Transclusions
111 | - Absolutizes post links
112 | - Changes file extension to .html
113 | - Sets template in prep for Jinja rendering later
114 | """
115 | return compose(
116 | html_doc(base_url),
117 | permalink.post_permalink
118 | )
--------------------------------------------------------------------------------
/lettersmith/cli/scaffold.py:
--------------------------------------------------------------------------------
1 | """
2 | Command line tool for scaffolding Lettersmith sites.
3 | """
4 | from pathlib import Path, PurePath
5 | from os import makedirs
6 | import shutil
7 | import argparse
8 | import random
9 |
10 |
11 | parser = argparse.ArgumentParser(
12 | description="""A tool for scaffolding Lettersmith sites""")
13 | parser.add_argument("project_path",
14 | type=Path,
15 | help="Path to your project directory")
16 | parser.add_argument("-t", "--type",
17 | type=str, default='blog', choices=["blog"],
18 | help="The type of project to scaffold")
19 | args = parser.parse_args()
20 |
21 |
22 | def main():
23 | project_path = Path(args.project_path)
24 | module_path = Path(__file__).parent
25 | scaffold_path = Path(
26 | module_path, "..", "package_data", "scaffold", args.type)
27 |
28 | try:
29 | shutil.copytree(scaffold_path, project_path)
30 |
31 | messages = (
32 | "Hocus pocus — Your new site is ready!",
33 | "Alakazam — Your new site is ready!",
34 | "Tada — Your new site is ready!",
35 | "A wild website appears!"
36 | )
37 |
38 | print(random.choice(messages))
39 | except FileExistsError:
40 | message = (
41 | f"Error: project_path \"{project_path}\" already exists.\n\n"
42 | "Your project_path should be a path to a directory that "
43 | "doesn't exist yet. Lettersmith will create the directory "
44 | "and write all of the project files into it. "
45 | "That way we won't accidentally overwrite anything important!"
46 | )
47 | print(message)
--------------------------------------------------------------------------------
/lettersmith/data.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | import yaml
3 | from lettersmith.path import glob_all
4 |
5 |
6 | YAML_EXT = (".yaml", ".yml")
7 | JSON_EXT = (".json")
8 |
9 |
10 | def _smart_read_data_file(file_path):
11 | """
12 | Given an open file object, this function will do its best to
13 | interpret structured data.
14 |
15 | Supported types:
16 |
17 | * .json
18 | * .yaml
19 | """
20 | ext = Path(file_path).suffix
21 | with open(file_path, "r") as f:
22 | if ext in JSON_EXT:
23 | return json.load(f)
24 | elif ext in YAML_EXT:
25 | return yaml.load(f)
26 | else:
27 | raise ValueError("Unsupported file type: {}".format(ext))
28 |
29 |
30 | def find(dir_path):
31 | """
32 | Create a data dictionary for the template. Each file in the list
33 | will be loaded (supported types include JSON, YAML). The structured
34 | data will be stored under a key corresponding to the filename
35 | (without extension).
36 |
37 | Returns a dictionary of structured Python data.
38 | """
39 | data = {}
40 | for file_path in glob_all(dir_path, ("*.yaml", "*.yml", "*.json")):
41 | try:
42 | stem = Path(file_path).stem
43 | data[stem] = _smart_read_data_file(file_path)
44 | except ValueError:
45 | pass
46 | return data
--------------------------------------------------------------------------------
/lettersmith/date.py:
--------------------------------------------------------------------------------
1 | from datetime import date, datetime
2 | from os import path
3 | from functools import singledispatch
4 |
5 |
6 | def read_file_times(pathlike):
7 | """
8 | Given a pathlike, return a tuple of `(created_time, modified_time)`.
9 | Both return values are datetime objects.
10 |
11 | If no value can be found, will return unix epoch for both.
12 | """
13 | path_str = str(pathlike)
14 | try:
15 | modified_time = datetime.fromtimestamp(path.getmtime(path_str))
16 | created_time = datetime.fromtimestamp(path.getctime(path_str))
17 | return created_time, modified_time
18 | except OSError:
19 | return EPOCH, EPOCH
20 |
21 |
22 | @singledispatch
23 | def to_datetime(x):
24 | """
25 | Given a date or datetime, return a datetime.
26 | Used to read datetime values from meta fields.
27 | """
28 | raise TypeError("read function not implemented for type {}".format(type(x)))
29 |
30 |
31 | @to_datetime.register(datetime)
32 | def datetime_to_datetime(dt):
33 | return dt
34 |
35 |
36 | @to_datetime.register(date)
37 | def date_to_datetime(d):
38 | """
39 | Convert a date to a datetime.
40 | """
41 | return datetime(d.year, d.month, d.day)
42 |
43 |
44 | @to_datetime.register(str)
45 | def date_to_datetime(s):
46 | """
47 | Convert an ISO 8601 date string to a datetime.
48 | """
49 | return parse_isoformat(s)
50 |
51 |
52 | def parse_isoformat(dt_str):
53 | """
54 | Parse an ISO 8601 date string into a datetime. Supports the following date
55 | styles:
56 |
57 | 2017-01-01
58 | 20170101
59 | 2017 01 01
60 | """
61 | try:
62 | return datetime.strptime(dt_str, "%Y-%m-%d")
63 | except ValueError:
64 | pass
65 | try:
66 | return datetime.strptime(dt_str, "%Y%m%d")
67 | except ValueError:
68 | pass
69 | return datetime.strptime(dt_str, "%Y %m %d")
70 |
71 |
72 | def format_isoformat(dt):
73 | """
74 | Format datetime as ISO 8601 https://en.wikipedia.org/wiki/ISO_8601
75 | '%Y-%m-%d'
76 | """
77 | return dt.date().isoformat()
78 |
79 |
80 | EPOCH = datetime.fromtimestamp(0)
81 | EPOCH_ISO_8601 = format_isoformat(EPOCH)
82 |
--------------------------------------------------------------------------------
/lettersmith/doc.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with Doc type.
3 |
4 | Docs are namedtuples that represent a file to be transformed.
5 | The `content` field of a doc contains the file contents, read as a
6 | Python string with UTF-8 encoding.
7 |
8 | Most lettersmith plugins transform Docs or iterables of Docs.
9 |
10 | For working with non-text files, images, binary files, or text files
11 | with other encodings, see `lettersmith.file` which stores the raw bytes
12 | instead of reading them into a Python string.
13 | """
14 | from pathlib import PurePath, Path
15 | import json
16 | from collections import namedtuple
17 | from functools import wraps
18 |
19 | import frontmatter
20 | import yaml
21 |
22 | from lettersmith.util import mix
23 | from lettersmith.date import read_file_times, EPOCH, to_datetime
24 | from lettersmith import path as pathtools
25 | from lettersmith import lens
26 | from lettersmith.lens import (
27 | Lens, lens_compose, get, put, key, over_with, update
28 | )
29 | from lettersmith.func import compose
30 |
31 |
32 | Doc = namedtuple("Doc", (
33 | "id_path", "output_path", "input_path", "created", "modified",
34 | "title", "content", "meta", "template"
35 | ))
36 | Doc.__doc__ = """
37 | Docs are namedtuples that represent a document to be transformed,
38 | and eventually written to disk.
39 |
40 | Docs contain a content field. This is a string that typically contains the
41 | contents of the file.
42 | """
43 |
44 |
45 | def create(id_path, output_path,
46 | input_path=None, created=EPOCH, modified=EPOCH,
47 | title="", content="", meta=None, template=""):
48 | """
49 | Create a Doc tuple, populating it with sensible defaults
50 | """
51 | return Doc(
52 | id_path=str(id_path),
53 | output_path=str(output_path),
54 | input_path=str(input_path) if input_path is not None else None,
55 | created=to_datetime(created),
56 | modified=to_datetime(modified),
57 | title=str(title),
58 | content=str(content),
59 | meta=meta if meta is not None else {},
60 | template=str(template)
61 | )
62 |
63 |
64 | def load(pathlike):
65 | """
66 | Loads a doc namedtuple from a file path.
67 | `content` field will contain contents of file.
68 | Typically, you decorate the doc later with meta and other fields.
69 |
70 | Returns a doc.
71 | """
72 | file_created, file_modified = read_file_times(pathlike)
73 | with open(pathlike, 'r') as f:
74 | content = f.read()
75 | title = pathtools.to_title(pathlike)
76 | return create(
77 | id_path=pathlike,
78 | output_path=pathlike,
79 | input_path=pathlike,
80 | created=file_created,
81 | modified=file_modified,
82 | title=title,
83 | meta={},
84 | content=content
85 | )
86 |
87 |
88 | def writeable(doc):
89 | """
90 | Return a writeable tuple for doc.
91 |
92 | writeable tuple is any 2-tuple of `output_path`, `bytes`.
93 | `lettersmith.write` knows how to write these tuples to disk.
94 | """
95 | return doc.output_path, doc.content.encode()
96 |
97 |
98 | id_path = Lens(
99 | lambda doc: doc.id_path,
100 | lambda doc, id_path: doc._replace(id_path=id_path)
101 | )
102 |
103 |
104 | output_path = Lens(
105 | lambda doc: doc.output_path,
106 | lambda doc, output_path: doc._replace(output_path=output_path)
107 | )
108 |
109 | ext = lens_compose(output_path, pathtools.ext)
110 |
111 | title = Lens(
112 | lambda doc: doc.title,
113 | lambda doc, title: doc._replace(title=title)
114 | )
115 |
116 |
117 | content = Lens(
118 | lambda doc: doc.content,
119 | lambda doc, content: doc._replace(content=content)
120 | )
121 |
122 |
123 | created = Lens(
124 | lambda doc: doc.created,
125 | lambda doc, created: doc._replace(created=created)
126 | )
127 |
128 |
129 | modified = Lens(
130 | lambda doc: doc.modified,
131 | lambda doc, modified: doc._replace(modified=modified)
132 | )
133 |
134 |
135 | meta = Lens(
136 | lambda doc: doc.meta,
137 | lambda doc, meta: doc._replace(meta=meta)
138 | )
139 |
140 |
141 | template = Lens(
142 | lambda doc: doc.template,
143 | lambda doc, template: doc._replace(template=template)
144 | )
145 |
146 |
147 | meta_summary = lens_compose(meta, key("summary", ""))
148 |
149 |
150 | def update_meta(doc, patch):
151 | """
152 | Mix keys from `patch` into `doc.meta`.
153 | """
154 | return update(meta, mix, doc, patch)
155 |
156 |
157 | def with_ext_html(doc):
158 | """
159 | Set doc extension to ".html"
160 | """
161 | return put(ext, doc, ".html")
162 |
163 |
164 | output_tld = compose(pathtools.tld, output_path.get)
165 | id_tld = compose(pathtools.tld, id_path.get)
166 |
167 |
168 | _infer_template = compose(
169 | pathtools.ext_html,
170 | pathtools.to_slug,
171 | id_tld
172 | )
173 |
174 |
175 | def autotemplate(doc):
176 | """
177 | Set template based on top-level directory in doc's id_path.
178 |
179 | E.g. if top-level-directory is "posts", template gets set to "posts.html".
180 | """
181 | if get(template, doc) != "":
182 | return doc
183 | else:
184 | return put(template, doc, _infer_template(doc))
185 |
186 |
187 | def with_template(t):
188 | """
189 | Set template `t`, but only if doc doesn't have one already.
190 | """
191 | def with_template_on_doc(doc):
192 | if get(template, doc) != "":
193 | return doc
194 | else:
195 | return put(template, doc, t)
196 | return with_template_on_doc
197 |
198 |
199 | def to_json(doc):
200 | """
201 | Serialize a doc as JSON-serializable data
202 | """
203 | return {
204 | "@type": "doc",
205 | "id_path": doc.id_path,
206 | "output_path": doc.output_path,
207 | "input_path": doc.input_path,
208 | "created": doc.created.timestamp(),
209 | "modified": doc.modified.timestamp(),
210 | "title": doc.title,
211 | "content": doc.content,
212 | "meta": doc.meta,
213 | "template": doc.template
214 | }
215 |
216 |
217 | def uplift_meta(doc):
218 | """
219 | Reads "magic" fields in the meta and uplifts their values to doc
220 | properties.
221 |
222 | We use this to uplift...
223 |
224 | - title
225 | - created
226 | - modified
227 | - permalink
228 | - template
229 |
230 | ...in the frontmatter, overriding original or default values on doc.
231 | """
232 | return doc._replace(
233 | title=doc.meta.get("title", doc.title),
234 | created=to_datetime(doc.meta.get("created", doc.created)),
235 | modified=to_datetime(doc.meta.get("modified", doc.modified)),
236 | output_path=doc.meta.get("permalink", doc.output_path),
237 | template=doc.meta.get("template", "")
238 | )
239 |
240 |
241 | class DocException(Exception):
242 | pass
243 |
244 |
245 | def annotate_exceptions(func):
246 | """
247 | Decorates a mapping function for docs, giving it a more useful
248 | exception message.
249 | """
250 | @wraps(func)
251 | def func_with_annotated_exceptions(doc):
252 | try:
253 | return func(doc)
254 | except Exception as e:
255 | msg = (
256 | 'Error encountered while mapping doc '
257 | '"{id_path}" with {module}.{func}.'
258 | ).format(
259 | id_path=doc.id_path,
260 | func=func.__qualname__,
261 | module=func.__module__
262 | )
263 | raise DocException(msg) from e
264 | return func_with_annotated_exceptions
265 |
266 |
267 | @annotate_exceptions
268 | def parse_frontmatter(doc):
269 | """
270 | Parse frontmatter as YAML. Set frontmatter on meta field, and
271 | remaining content on content field.
272 |
273 | If there is no frontmatter, will set an empty object on meta field,
274 | and leave content as-is.
275 | """
276 | meta, content = frontmatter.parse(doc.content)
277 | return doc._replace(
278 | meta=meta,
279 | content=content
280 | )
281 |
282 |
283 | uplift_frontmatter = compose(uplift_meta, parse_frontmatter)
284 |
285 |
286 | def renderer(render):
287 | """
288 | Create a renderer for doc content using a string rendering function.
289 |
290 | Will also annotate any exceptions that happen during rendering,
291 | transforming them into DocExceptions that will record the doc's
292 | id_path and the render function where exception occurred.
293 |
294 | Can be used as a decorator.
295 | """
296 | return annotate_exceptions(over_with(content, render))
--------------------------------------------------------------------------------
/lettersmith/docs.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with collections of docs
3 | """
4 | from fnmatch import fnmatch
5 | from lettersmith import path as pathtools
6 | from lettersmith import doc as Doc
7 | from lettersmith import query
8 | from lettersmith.func import composable, compose
9 | from lettersmith.lens import get
10 |
11 |
12 | load = query.maps(Doc.load)
13 |
14 |
15 | def find(glob):
16 | """
17 | Load all docs under input path that match a glob pattern.
18 |
19 | Example:
20 |
21 | docs.find("posts/*.md")
22 | """
23 | return load(pathtools.glob_files(".", glob))
24 |
25 |
26 | @composable
27 | def remove_id_path(docs, id_path):
28 | """
29 | Remove docs with a given id_path.
30 | """
31 | for doc in docs:
32 | if doc.id_path != id_path:
33 | yield doc
34 |
35 |
36 | @composable
37 | def matching(docs, glob):
38 | """
39 | Filter an iterator of docs to only those docs whos id_path
40 | matches a unix-style glob pattern.
41 | """
42 | for doc in docs:
43 | if fnmatch(doc.id_path, glob):
44 | yield doc
45 |
46 |
47 | @composable
48 | def filter_siblings(docs, id_path):
49 | """
50 | Filter a list of dicts with `id_path`, returning a generator
51 | yielding only those dicts who's id_path is a sibling to
52 | `id_path`.
53 | """
54 | for doc in docs:
55 | if pathtools.is_sibling(id_path, doc.id_path):
56 | yield doc
57 |
58 |
59 | remove_drafts = query.rejects(compose(pathtools.is_draft, Doc.id_path.get))
60 | remove_index = query.rejects(compose(pathtools.is_index, Doc.id_path.get))
61 | dedupe = query.dedupes(Doc.id_path.get)
62 | uplift_frontmatter = query.maps(Doc.uplift_frontmatter)
63 | sort_by_created = query.sorts(Doc.created.get, reverse=True)
64 | sort_by_modified = query.sorts(Doc.modified.get, reverse=True)
65 | sort_by_title = query.sorts(Doc.title.get)
66 | autotemplate = query.maps(Doc.autotemplate)
67 | with_ext_html = query.maps(Doc.with_ext_html)
68 |
69 |
70 | def most_recent(n):
71 | """
72 | Get most recent `n` docs, ordered by created.
73 | """
74 | return compose(
75 | query.takes(n),
76 | sort_by_created
77 | )
78 |
79 |
80 | def with_template(template):
81 | """
82 | Set template, but only if doc doesn't have one already.
83 | """
84 | return query.maps(Doc.with_template(template))
85 |
86 |
87 | def renderer(render):
88 | """
89 | Create a renderer for docs using a string render function.
90 |
91 | Can be used as a decorator.
92 | """
93 | return query.maps(Doc.renderer(render))
--------------------------------------------------------------------------------
/lettersmith/edge.py:
--------------------------------------------------------------------------------
1 | """
2 | Data structure for describing directed graph connections.
3 | """
4 | from collections import namedtuple
5 |
6 |
7 | Edge = namedtuple("Edge", ("tail", "head"))
8 | Edge.__doc__ = """
9 | A directed edge that points from one thing to another.
10 | If you imagine an arrow, tail is the base of the arrow, and head is
11 | the pointy arrow head.
12 | """
--------------------------------------------------------------------------------
/lettersmith/file.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with files.
3 |
4 | Files are namedtuples that represent the raw bytes in a file to be
5 | copied or transformed.
6 | """
7 | from collections import namedtuple
8 | from pathlib import PurePath
9 | from lettersmith.date import read_file_times, EPOCH, to_datetime
10 | from lettersmith import doc as Doc
11 |
12 |
13 | File = namedtuple("File", (
14 | "id_path", "output_path", "input_path",
15 | "created", "modified", "blob"
16 | ))
17 | File.__doc__ = """
18 | Files are namedtuples that represent the raw bytes in a file to be
19 | copied or transformed.
20 |
21 | Files contain a `blob` field that contains the bytes of the file.
22 | """
23 |
24 |
25 | def create(id_path, output_path, blob,
26 | input_path=None, created=EPOCH, modified=EPOCH):
27 | """
28 | Create a File tuple, populating it with sensible defaults
29 | """
30 | return File(
31 | id_path=str(id_path),
32 | output_path=str(output_path),
33 | input_path=str(input_path) if input_path is not None else None,
34 | created=to_datetime(created),
35 | modified=to_datetime(modified),
36 | blob=bytes(blob)
37 | )
38 |
39 |
40 | def load(pathlike):
41 | """
42 | Loads a File namedtuple from a file path.
43 | `blob` field will contain bytes of file.
44 | Returns a File.
45 | """
46 | file_created, file_modified = read_file_times(pathlike)
47 | with open(pathlike, 'rb') as f:
48 | blob = f.read()
49 | return create(
50 | id_path=pathlike,
51 | output_path=pathlike,
52 | input_path=pathlike,
53 | created=file_created,
54 | modified=file_modified,
55 | blob=blob
56 | )
57 |
58 |
59 | def writeable(file):
60 | """
61 | Return a writeable tuple for file.
62 |
63 | writeable tuple is any 2-tuple of `output_path`, `bytes`.
64 | `lettersmith.write` knows how to write these tuples to disk.
65 | """
66 | return file.output_path, file.blob
67 |
68 |
69 | def to_doc(file):
70 | """
71 | Create a Doc from a File.
72 | """
73 | return Doc.create(
74 | id_path=file.id_path,
75 | output_path=file.output_path,
76 | input_path=file.input_path,
77 | created=file.created,
78 | modified=file.modified,
79 | content=file.blob.decode()
80 | )
81 |
82 |
83 | def from_doc(file):
84 | """
85 | Create a File from a Doc.
86 | """
87 | return create(
88 | id_path=doc.id_path,
89 | output_path=doc.output_path,
90 | input_path=doc.input_path,
91 | created=doc.created,
92 | modified=doc.modified,
93 | blob=doc.content.encode()
94 | )
--------------------------------------------------------------------------------
/lettersmith/files.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with collections of files
3 | """
4 | from lettersmith.path import glob_files
5 | from lettersmith import file as File
6 | from lettersmith import query
7 |
8 |
9 | load = query.maps(File.load)
10 |
11 |
12 | def find(glob):
13 | """
14 | Load all docs under input path that match a glob pattern.
15 |
16 | Example:
17 |
18 | docs.find("posts/*.md")
19 | """
20 | return load(glob_files(".", glob))
21 |
22 |
23 | to_doc = query.maps(File.to_doc)
24 | from_doc = query.maps(File.from_doc)
--------------------------------------------------------------------------------
/lettersmith/func.py:
--------------------------------------------------------------------------------
1 | """
2 | Tools for working with higher-order functions.
3 | """
4 | from functools import reduce, wraps
5 |
6 |
7 | def id(x):
8 | """
9 | The id function.
10 | """
11 | return x
12 |
13 |
14 | def _compose2(b, a):
15 | """Compose 2 functions"""
16 | def composed(x):
17 | """Composed function"""
18 | return b(a(x))
19 | return composed
20 |
21 |
22 | def compose(*funcs):
23 | """Compose n functions from right to left"""
24 | return reduce(_compose2, funcs, id)
25 |
26 |
27 | def thrush(*funcs):
28 | """
29 | Compose n functions from left to right.
30 |
31 | This is the same as compose, but using left-to-right application
32 | instead of right-to-left application.
33 |
34 | What's with the name?
35 |
36 | Following on Racket's naming convention
37 | https://docs.racket-lang.org/point-free/index.html
38 |
39 | It's named after
40 | https://en.wikipedia.org/wiki/To_Mock_a_Mockingbird
41 | """
42 | return reduce(_compose2, reversed(funcs), id)
43 |
44 |
45 | def _apply_to(value, func):
46 | """
47 | Apply value to a single argument function.
48 | """
49 | return func(value)
50 |
51 |
52 | def pipe(value, *funcs):
53 | """
54 | Pipe value through a series of single argument functions.
55 | This is basically a function version of a pipeline operator.
56 |
57 | pipe(value, a, b, c)
58 |
59 | is equivalent to
60 |
61 | c(b(a(value)))
62 |
63 | Returns transformed value.
64 | """
65 | return reduce(_apply_to, funcs, value)
66 |
67 |
68 | def composable(func):
69 | """
70 | Decorator to transform a function into a composable function
71 | that consumes all of the "rest" of the arguments (everything
72 | after the first argument), then returns a bound function taking
73 | one argument (the first argument).
74 | """
75 | @wraps(func)
76 | def composable_func(*args, **kwargs):
77 | return rest(func, *args, **kwargs)
78 | return composable_func
79 |
80 |
81 | def rest(func, *args, **kwargs):
82 | """
83 | Binds the "rest" of the arguments, then returns a bound
84 | function taking one argument (the first argument).
85 | """
86 | @wraps(func)
87 | def bound(first):
88 | return func(first, *args, **kwargs)
89 | return bound
--------------------------------------------------------------------------------
/lettersmith/html.py:
--------------------------------------------------------------------------------
1 | """
2 | HTML markup -- a very unfancy markup language that automatically wraps
3 | bare lines with `
` tags, but only if they don't contain a
4 | block-level element.
5 |
6 | Example:
7 |
8 | Bare lines are wrapped in paragraph tags.
9 |
10 | Blank spaces are ignored.
11 |
12 | You can use HTML like bold, or italic in paragraphs.
13 |
14 |
15 | Indented lines, or lines containing block-level elements, will
16 | not be wrapped.
17 |
18 |
19 | That's it!
20 | """
21 | import re
22 | from collections import namedtuple
23 | from lettersmith.stringtools import first_sentence
24 | from lettersmith import docs as Docs
25 |
26 |
27 | def strip_html(html_str):
28 | """Remove html tags from a string."""
29 | return re.sub('<[^<]+?>', '', html_str)
30 |
31 |
32 | def get_summary(doc):
33 | """
34 | Get summary for doc. Uses "summary" meta field if it exists.
35 | Otherwise, generates a summary by truncating doc content.
36 | """
37 | try:
38 | return strip_html(doc.meta["summary"])
39 | except KeyError:
40 | return first_sentence(strip_html(doc.content))
41 |
42 |
43 | class RenderError(Exception):
44 | pass
45 |
46 |
47 | Token = namedtuple("Token", ("type", "body"))
48 | Token.__doc__ = """
49 | A parsed token from the markup.
50 | """
51 |
52 | _BLOCK_ELS = r"?(address|article|aside|blockquote|br|details|dialog|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|li|main|nav|ol|p|pre|section|table|ul)\b[^>]*>"
53 |
54 |
55 | def _tokenize(lines):
56 | """
57 | Turn lines into tagged tokens.
58 | """
59 | for line in lines:
60 | line_clean = line.strip()
61 | if line_clean is "":
62 | pass
63 | elif line.startswith(" "):
64 | yield Token("html", line_clean)
65 | elif re.match(_BLOCK_ELS, line):
66 | yield Token("html", line_clean)
67 | else:
68 | yield Token("p", line_clean)
69 |
70 |
71 | def _render_token(token):
72 | """
73 | Render HTML tokens. This markup language is very simple, so we only
74 | have two.
75 | """
76 | if token.type is "html":
77 | return token.body
78 | elif token.type is "p":
79 | return "
{}
".format(token.body)
80 | else:
81 | raise RenderError("Unknown token type {}".format(token.type))
82 |
83 |
84 | def render_html(text):
85 | """
86 | Renders text as HTML.
87 | """
88 | lines = text.splitlines()
89 | return "\n".join(_render_token(token) for token in _tokenize(lines))
90 |
91 |
92 | content = Docs.renderer(render_html)
--------------------------------------------------------------------------------
/lettersmith/io.py:
--------------------------------------------------------------------------------
1 | """
2 | File utilities
3 | """
4 | import shutil
5 | from pathlib import Path, PurePath
6 |
7 |
8 | def write_file_deep(pathlike, content, mode="w"):
9 | """Write a file to filepath, creating directories if necessary"""
10 | file_path = Path(pathlike)
11 | file_path.parent.mkdir(exist_ok=True, parents=True)
12 | with open(file_path, mode) as f:
13 | f.write(content)
--------------------------------------------------------------------------------
/lettersmith/jinjatools.py:
--------------------------------------------------------------------------------
1 | import random
2 | import itertools
3 | from datetime import datetime
4 |
5 | from jinja2 import Environment, FileSystemLoader
6 |
7 | from lettersmith import util
8 | from lettersmith import docs as Docs
9 | from lettersmith import doc as Doc
10 | from lettersmith import query
11 | from lettersmith.lens import get, put
12 | from lettersmith import path as pathtools
13 | from lettersmith.markdowntools import markdown
14 |
15 |
16 | def _choice(iterable):
17 | return random.choice(tuple(iterable))
18 |
19 |
20 | def _shuffle(iterable):
21 | """
22 | Shuffles the elements in an iterable, returning a new list.
23 |
24 | Will collect any iterable before sampling.
25 | This prevents annoying in-template errors, where collecting
26 | an iterator into a tuple can be non-trivial.
27 | """
28 | t = tuple(iterable)
29 | return random.sample(t, k=len(t))
30 |
31 |
32 | def _sample(iterable, k):
33 | """
34 | Will collect any iterable before sampling.
35 | This prevents annoying in-template errors, where collecting
36 | an iterator into a tuple can be non-trivial.
37 | """
38 | l = list(iterable)
39 | try:
40 | return random.sample(l, k)
41 | except ValueError:
42 | return l
43 |
44 |
45 | def _permalink(base_url):
46 | def permalink_bound(output_path):
47 | return pathtools.to_url(output_path, base_url)
48 | return permalink_bound
49 |
50 |
51 | class FileSystemEnvironment(Environment):
52 | def __init__(self, templates_path, filters={}, context={}):
53 | loader = FileSystemLoader(templates_path)
54 | super().__init__(loader=loader)
55 | self.filters.update(filters)
56 | self.globals.update(context)
57 |
58 |
59 | TEMPLATE_FUNCTIONS = {
60 | "sorted": sorted,
61 | "len": len,
62 | "islice": itertools.islice,
63 | "choice": _choice,
64 | "sample": _sample,
65 | "shuffle": _shuffle,
66 | "to_url": pathtools.to_url,
67 | "join": util.join,
68 | "tuple": tuple
69 | }
70 |
71 |
72 | class LettersmithEnvironment(FileSystemEnvironment):
73 | """
74 | Specialized version of default Jinja environment class that
75 | offers additional filters and environment variables.
76 | """
77 | def __init__(self, templates_path, filters={}, context={}):
78 | loader = FileSystemLoader(templates_path)
79 | super().__init__(
80 | templates_path,
81 | filters=TEMPLATE_FUNCTIONS,
82 | context=TEMPLATE_FUNCTIONS
83 | )
84 | self.filters.update(filters)
85 | self.globals.update(context)
86 |
87 |
88 | def should_template(doc):
89 | """
90 | Check if a doc should be templated. Returns a bool.
91 | """
92 | return get(Doc.template, doc) is not ""
93 |
94 |
95 | def jinja(templates_path, base_url, context={}, filters={}):
96 | """
97 | Wraps up the gory details of creating a Jinja renderer.
98 | Returns a render function that takes a doc and returns a rendered doc.
99 | Template comes preloaded with Jinja default filters, and
100 | Lettersmith default filters and globals.
101 | """
102 | now = datetime.now()
103 | env = LettersmithEnvironment(
104 | templates_path,
105 | filters={"permalink": _permalink(base_url), **filters},
106 | context={"now": now, **context}
107 | )
108 |
109 | @query.maps
110 | @Doc.annotate_exceptions
111 | def render(doc):
112 | if should_template(doc):
113 | template = env.get_template(doc.template)
114 | rendered = template.render({"doc": doc})
115 | return put(Doc.content, doc, rendered)
116 | else:
117 | return doc
118 |
119 | return render
--------------------------------------------------------------------------------
/lettersmith/lens.py:
--------------------------------------------------------------------------------
1 | """
2 | A minimal implementation of Haskel-style lenses
3 |
4 | Inspired by Elm's Focus library and Racket's Lenses library.
5 |
6 | Lenses let you create getters and setters for complex data structures.
7 | The combination of a getter and setter is called a lens.
8 |
9 | Lenses can be composed to provide a way to do deep reads and deep writes
10 | to complex data structures.
11 | """
12 | from collections import namedtuple
13 | from functools import reduce
14 |
15 |
16 | Lens = namedtuple("Lens", ("get", "put"))
17 | Lens.__doc__ = """
18 | Container type for Lenses.
19 | A lens is any structure with `get` and `put` functions that
20 | follow the lens signature.
21 | """
22 |
23 |
24 | def _lens_compose2(big_lens, small_lens):
25 | """
26 | Compose 2 lenses. This allows you to create a lens that can
27 | do a deep get/set.
28 | """
29 | def get(big):
30 | """
31 | Lens `get` method (composed)
32 | """
33 | return small_lens.get(big_lens.get(big))
34 |
35 | def put(big, small):
36 | """
37 | Lens `update` method (composed)
38 | """
39 | return big_lens.put(
40 | big,
41 | small_lens.put(big_lens.get(big), small)
42 | )
43 |
44 | return Lens(get, put)
45 |
46 |
47 | def lens_compose(big_lens, *smaller_lenses):
48 | """
49 | Compose many lenses
50 | """
51 | return reduce(_lens_compose2, smaller_lenses, big_lens)
52 |
53 |
54 | def get(lens, big):
55 | """
56 | Get a value from `big` using `lens`.
57 | """
58 | return lens.get(big)
59 |
60 |
61 | def put(lens, big, small):
62 | """
63 | Set a value in `big`.
64 | """
65 | return lens.put(big, small)
66 |
67 |
68 | def over(lens, func, big):
69 | """
70 | Map value(s) in `big` using a `mapping` function.
71 | """
72 | return put(lens, big, func(get(lens, big)))
73 |
74 |
75 | def over_with(lens, func):
76 | """
77 | Given a lens and a function, returns a single-argument function
78 | that will map over value in `big` using `func`, and returning
79 | a new instance of `big`.
80 | """
81 | def over_bound(big):
82 | """
83 | Map value(s) in `big` using a bound mapping function.
84 | """
85 | return over(lens, func, big)
86 | return over_bound
87 |
88 |
89 | def update(lens, up, big, msg):
90 | """
91 | Update `big` through an update function, `up` which takes the
92 | current small, and a `msg`, and returns a new small.
93 | """
94 | return put(lens, big, up(get(lens, big), msg))
95 |
96 |
97 | def key(k, default=None):
98 | """
99 | Lens to get and set a key on a dictionary, with default value.
100 | Because it allows for a default, it technically violates the
101 | lens laws. However, in practice, it's too darn useful not to have.
102 | """
103 | def get(big):
104 | """
105 | Get key from dict
106 | """
107 | return big.get(k, default)
108 |
109 | def put(big, small):
110 | """
111 | Put value in key from dict, returning new dict.
112 | """
113 | # Check that we're actually making a change before creating
114 | # a new dict.
115 | if big.get(k, default) == small:
116 | return big
117 | else:
118 | return {**big, k: small}
119 |
120 | return Lens(get, put)
121 |
122 |
123 | def _pick(d, keys):
124 | return {k: d[k] for k in keys}
125 |
126 |
127 | def keys(*keys):
128 | """
129 | Lens to get and set multiple keys on a dictionary. Note that
130 | no default values are allowed.
131 | """
132 | def get(big):
133 | """
134 | Get key from dict
135 | """
136 | return _pick(big, keys)
137 |
138 | def put(big, small):
139 | """
140 | Put value in key from dict, returning new dict.
141 | """
142 | patch = _pick(small, keys)
143 | return {**big, **patch}
144 |
145 | return Lens(get, put)
--------------------------------------------------------------------------------
/lettersmith/markdowntools.py:
--------------------------------------------------------------------------------
1 | from commonmark import commonmark
2 | from lettersmith.html import strip_html
3 | from lettersmith import docs as Docs
4 | from lettersmith.func import compose
5 |
6 |
7 | markdown = commonmark
8 | strip_markdown = compose(strip_html, markdown)
9 | content = Docs.renderer(markdown)
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/build.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | A blog-aware Lettersmith build script. Modify it to your heart's content.
4 | """
5 | from lettersmith import *
6 |
7 | # Configuration
8 | base_url = "http://yourwebsite.com"
9 | site_title = "My very cool website"
10 | site_description = "A very cool website"
11 | site_author = "A very cool person"
12 |
13 | # Load data directory
14 | template_data = data.find("data")
15 |
16 | # Load static and binary files
17 | static = files.find("static/**/*")
18 |
19 | # Load post docs and pipe through plugins
20 | posts = pipe(
21 | docs.find("post/*.md"),
22 | blog.markdown_post(base_url),
23 | docs.sort_by_created,
24 | tuple
25 | )
26 |
27 | # Load page docs and pipe through plugins
28 | pages = pipe(
29 | docs.find("page/*.md"),
30 | blog.markdown_page(base_url, relative_to="page")
31 | )
32 |
33 | posts_rss_doc = pipe(posts, rss.rss(
34 | base_url=base_url,
35 | title=site_title,
36 | description=site_description,
37 | author=site_author,
38 | output_path="posts.xml"
39 | ))
40 |
41 | archive_doc = pipe(posts, archive.archive("archive/index.html"))
42 | recent_posts = pipe(posts, stub.stubs, query.takes(5))
43 |
44 | posts_and_pages = (*posts, *pages)
45 |
46 | sitemap_doc = pipe(posts_and_pages, sitemap.sitemap(base_url))
47 |
48 | context = {
49 | "rss_docs": (posts_rss_doc,),
50 | "recent": recent_posts,
51 | "site": {
52 | "title": site_title,
53 | "description": site_description,
54 | "author": site_author
55 | },
56 | "data": template_data,
57 | "base_url": base_url
58 | }
59 |
60 | rendered_docs = pipe(
61 | (sitemap_doc, posts_rss_doc, archive_doc, *posts_and_pages),
62 | jinjatools.jinja("template", base_url, context)
63 | )
64 |
65 | write(chain(static, rendered_docs), directory="public")
66 |
67 | print("Done!")
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/data/.empty:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gordonbrander/lettersmith_py/96ddaf1268ac53062b2e7b1d05d06dc092865666/lettersmith/package_data/scaffold/blog/data/.empty
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/page/.empty:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gordonbrander/lettersmith_py/96ddaf1268ac53062b2e7b1d05d06dc092865666/lettersmith/package_data/scaffold/blog/page/.empty
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/page/About.md:
--------------------------------------------------------------------------------
1 | A bit about this site.
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/page/Page A.md:
--------------------------------------------------------------------------------
1 | Example link to [[page B]].
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/page/Page B.md:
--------------------------------------------------------------------------------
1 | Example link to [[page A]].
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/page/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Home
3 | template: "index.html"
4 | ---
5 |
6 | This is your home page.
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/post/Example Post.md:
--------------------------------------------------------------------------------
1 | ---
2 | created: 2019-11-24
3 | ---
4 |
5 | Content
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/static/.empty:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gordonbrander/lettersmith_py/96ddaf1268ac53062b2e7b1d05d06dc092865666/lettersmith/package_data/scaffold/blog/static/.empty
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/template/_base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{site.title}}
6 |
7 |
8 | {% block body %}{% endblock %}
9 |
10 |
--------------------------------------------------------------------------------
/lettersmith/package_data/scaffold/blog/template/archive.html:
--------------------------------------------------------------------------------
1 | {% extends "_base.html" %}
2 |
3 | {% block body %}
4 | {% for stub in doc.meta.archive %}
5 |
6 |