├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.json ├── content ├── docs │ ├── cli.md │ ├── index.md │ ├── markdown.md │ ├── tpl.md │ └── tree.md ├── index.md └── start.md ├── public ├── css │ └── main.css ├── docs │ ├── cli │ │ └── index.html │ ├── index.html │ ├── markdown │ │ └── index.html │ ├── tpl │ │ └── index.html │ └── tree │ │ └── index.html ├── img │ ├── my-blog-about-screenshot.png │ ├── my-blog-draft-screenshot.png │ ├── my-blog-screenshot.png │ ├── my-blog-styled-screenshot.png │ └── sistine-screenshot.png ├── index.html ├── index.xml ├── js │ └── main.js └── start │ └── index.html ├── sistine ├── src ├── build.ink ├── help.ink ├── main.ink └── tpl.ink ├── static ├── css │ └── main.css ├── img │ ├── my-blog-about-screenshot.png │ ├── my-blog-draft-screenshot.png │ ├── my-blog-screenshot.png │ ├── my-blog-styled-screenshot.png │ └── sistine-screenshot.png └── js │ └── main.js ├── test └── main.ink ├── tpl ├── docs.html ├── index.html ├── parts │ ├── breadcrumbs.html │ ├── footer.html │ ├── head.html │ ├── header.html │ └── scripts.html └── rss.xml └── vendor ├── cli.ink ├── escape.ink ├── json.ink ├── md.ink ├── quicksort.ink ├── reader.ink ├── std.ink ├── str.ink └── suite.ink /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/.gitignore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Linus Lee 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: run 2 | 3 | # build site 4 | run: 5 | ink src/main.ink 6 | 7 | # run all tests under test/ 8 | check: 9 | ink ./test/main.ink 10 | t: check 11 | 12 | fmt: 13 | inkfmt fix src/*.ink test/*.ink 14 | f: fmt 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sistine 🏰 2 | 3 | **Sistine** is a simple, flexible, productive static site generator written in [Ink](https://dotink.co/) and built on [Merlot](https://github.com/thesephist/merlot)’s Markdown engine. You can see a live demo of a Sistine site [on the Sistine docs website](https://sistine.vercel.app/). 4 | 5 | ![A screenshot of the Sistine docs site, built with Sistine](/static/img/sistine-screenshot.png) 6 | 7 | ## Documentation 8 | 9 | Sistine's documentation lives on its own website at **[sistine.vercel.app](https://sistine.vercel.app)**. There, you'll find information on how to install and use Sistine, as well as a detailed reference for its templating language. 10 | 11 | ## Development 12 | 13 | This repository technically contains two projects. First, the Ink source code for the Sistine static site generator; and second, the documentation site for Sistine, which is generated by Sistine itself from assets in this repo. Both parts of this repository uses a Makefile to manage common build commands. 14 | 15 | ### Sistine, the tool 16 | 17 | Sistine's source code mostly lives in `./src`, with vendored dependencies copied into `./vendor`. Tests for Sistine utilities are in `./test`. 18 | 19 | - `make check` or `make t` runs all tests in the repository. 20 | - `make fmt` or `make f` formats all Ink files (including tests) in the repository, if you have [`inkfmt`](https://github.com/thesephist/inkfmt) installed. 21 | 22 | ### Sistine, the website 23 | 24 | The Sistine documentation website is a normal Sistine project, living in this repository. The repository is set up with Vercel so that contents of `./public` auto-deploys on every commit to `main`. Other parts of the website, like the content pages and templates, are set up exactly as a normal Sistine project, in `./content` and `./tpl` respectively. 25 | 26 | - `make` will run the in-repository copy of Sistine to build the documentation site to `./public`. 27 | 28 | ### Contributing & reporting issues 29 | 30 | Given that it's currently quite slow and written in Ink, you probably shouldn't use it for anything important. But if you are interested, and want to ask questions about how it works or what's coming next, feel free to [reach out on Twitter](https://twitter.com/thesephist) or file a [GitHub issue](https://github.com/thesephist/sistine/issues). 31 | 32 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sistine", 3 | "origin": "https://sistine.vercel.app", 4 | "description": "A simple, flexible, productive static site engine written in Ink" 5 | } 6 | -------------------------------------------------------------------------------- /content/docs/cli.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sistine CLI 3 | order: 0 4 | description: A catalog of the Sistine command line commands and options 5 | --- 6 | 7 | Sistine is distributed as a single command-line utility called `sistine`, written in Ink. You can find a bash script that invokes it in the repository as `sistine`. The script errors and bails if Ink is not found on the system `$PATH`. 8 | 9 | _(If you know what you're doing, you can also run the Sistine CLI manually by running `./src/main.ink` with Ink.)_ 10 | 11 | **`sistine build`** builds the static site described and configured in the current folder into `./public`. If any necessary files or folders cannot be found, it will error. This is the default command that runs when `sistine` is invoked without any arguments. 12 | 13 | **`sistine help`** prints the help menu in the CLI and exits. 14 | -------------------------------------------------------------------------------- /content/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | --- 4 | 5 | The documentation contains detailed guides and references for how to build static sites with Sistine. To get acquainted and get a feel for Sistine, you may prefer the [Get started](/start/) guide. 6 | 7 | -------------------------------------------------------------------------------- /content/docs/markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sistine Markdown 3 | order: 3 4 | description: The extra features that Sistine's Markdown parser supports, and where it deviates from the norm 5 | --- 6 | 7 | Sistine's Markdown engine comes from the [Merlot](https://github.com/thesephist/merlot) Markdown editor. As a result, every Markdown feature supported by Merlot is supported in Sistine, and the ones not yet supported in Merlot are not yet here in Sistine, either. Most features of standard Markdown are supported, however, including 8 | 9 | - Inline markup: _italic_, **bold**, ~strikethrough~, `monospace` 10 | - Bulleted and numbered lists nested to arbitrary depths 11 | - Code blocks with `\`\`\`` 12 | - Block quotes with `>` 13 | - Headings beginning with a number of `#`s 14 | - Inline HTML with the `!html` command 15 | 16 | Notable features that are not yet supported include 17 | 18 | - Table of contents generation 19 | - Anchor links to headings (being able to link to `some-page/#some-heading`) 20 | - Code block syntax highlighting 21 | - Multi-paragraph list items 22 | 23 | I will probably add them to Merlot (and thus Sistine) as I come to need those features out of these apps. 24 | 25 | ## Front matter in content pages 26 | 27 | Every content page in the `./content` folder of a Sistine site should be a Markdown file, optionally beginning with _front matter_. Front matter is a list of key-value pairs that come between two triple-dashed lines. For example, the front matter for this page is something like 28 | 29 | ``` 30 | --- 31 | title: Sistine Markdown 32 | order: 3 33 | description: The extra features ... 34 | --- 35 | 36 | ( rest of page Markdown ... ) 37 | ``` 38 | 39 | Front matter should always begin and end with three dashes. if a particular page does not need any page variables, it may omit the front matter entirely, and the entire page will be parsed as simple Markdown. 40 | 41 | Note that unlike in most popular static site generators, front matter in Sistine do not support the full YAML syntax -- they're simply `key: value` pairs between triple dashes. 42 | 43 | Every key in the front matter of a content page defines a [page variable](/docs/tpl/) for Sistine templates to use when rendering that page. All variables are parsed and treated as raw strings. In other words the `order` variable of this page is the string "3". 44 | 45 | -------------------------------------------------------------------------------- /content/docs/tpl.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Templating system 3 | order: 1 4 | starred: true 5 | description: The canonical reference manual for Sistine's templating language and system 6 | --- 7 | 8 | Sistine's main job is to take each page from the content directory and render it into a full HTML page using a _template_. Sistine's page templates are HTML files with extra template directives modeled after Ink's `std.format` function. The rest of this page details this templating system, and how Sistine finds these templates. 9 | 10 | ## Templating language and directives 11 | 12 | Ink's templating language uses double curly braces like `{{ these }}` to denote special instructions for the templating engine. If you must include double curly braces in your template to be displayed, escape the second brace, like `{\\{`, or use the HTML entity `{` to denote a curly brace. 13 | 14 | Sistine provides the following functions in a template. 15 | 16 | ### Display a variable or property 17 | 18 | `{{ foo.bar }}` resolves to the value of `bar` in the object `foo` in the current parameter dictionary. For example, if the page parameters looked like this 19 | 20 | ``` 21 | { 22 | title: 'Hello, World!' 23 | foo: { 24 | bar: [10, 20, 30] 25 | baz: { 26 | quux: 'Goodbye!' 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | The following are all valid. 33 | 34 | - `{{ title }}` 35 | - `{{ foo.bar }}` (though this will print the raw list object) 36 | - `{{ foo.bar.2 }}` (prints `30`) 37 | - `{{ foo.baz.quux }}` 38 | 39 | Accessing an undefined or null value will not error -- it will simply render an empty string. This behavior is nice for dealing with optional values, like `page.draft` which may be usually false. 40 | 41 | ### Conditional if/else expressions 42 | 43 | `{{ if foo }} X {{ else }} Y {{ end }}` renders X if value `foo` is truthy, `Y` otherwise. In determining truthiness, the following values and their string forms are considered false, and any other value is considered true: 44 | 45 | - `0` 46 | - `''` 47 | - `()` 48 | - `{}` 49 | - `false` 50 | 51 | An idiomatic trick is to check `{{ if page.some_list }}...{{ end }}` to check whether a list is empty. 52 | 53 | ### Loops through a list or object values 54 | 55 | The loop directive is a bit more complex. The full form looks like the following, where the parts in square brackets are optional. 56 | 57 | ``` 58 | {{ each foo [by bar] [asc|desc] [limit] }} 59 | X 60 | {{ else }} 61 | Y 62 | {{ end }} 63 | ``` 64 | 65 | If `foo` is not empty, this directive loops through every value in the list or object `foo` ordered by each item's property `bar` and renders X for each value; if the list is empty, this renders Y. The `asc|desc` declaration determines whether the sort is in ascending or descending order, and `limit` is the optional, maximum number of items to be looped through, like a limit clause in SQL. They are optional, but a limit must follow an asc/desc declaration. For example, a common format for a reverse-chronological blog post listing page may include 66 | 67 | ``` 68 | {{ each page.pages by date desc }} 69 | {{ -- post-listing -- }} 70 | {{ else }} 71 |

No posts yet.

72 | {{ end }} 73 | ``` 74 | 75 | Besides the properties that are normally a part of each value in the list, within each `{{ each }}` section, a template has access to three special variables: 76 | 77 | - `i` is the index in the loop, starting at 0 78 | - `*` is the template parameter immediately outside the loop, useful for accessing out-of-scope variables from within the loop like `{{ *.site.name }}` 79 | - `_` is the parent value, _i.e._ the thing being iterated over 80 | 81 | ### Escaping for HTML 82 | 83 | `{{ escape foo }}` escapes the value of variable `foo` for HTML. This escapes _at least_ `<` and `&` for safe display of HTML code. 84 | 85 | ### Partial template embeds 86 | 87 | Partial templates are defined by placing an HTML file into `./tpl/parts`. They are referred to by their base filename in other templates. Partial templates can refer to other partial templates, but normal templates cannot refer to other normal templates by their name. For example, to share a common header part across all templates, we may place a `header.html` into the partial templates folder, then write 88 | 89 | ``` 90 | {{ -- header -- }} 91 | ``` 92 | 93 | This will invoke Sistine to search for this partial template in `./tpl/parts/header.html`. If one is not found, this directive will be ignored, but you'll see an error message from Sistine in the output. 94 | 95 | ## Page template variables 96 | 97 | All page templates are passed a dictionary with values for: 98 | 99 | - `site`, containing site-wide metadata, imported from `./config.json` 100 | - `page`, containing data about that specific page, including URLS, file paths, the rendered Markdown content, child and parent pages, and any other parameters defined for the page in the page's [front matter](/docs/markdown/) 101 | 102 | In general, a template begins rendering with these variables, plus any user-defined ones. 103 | 104 | ```ink 105 | site { 106 | name 107 | origin 108 | description 109 | } 110 | page { 111 | path // URL of the page 112 | publicPath // path to file in ./public 113 | contentPath // path to file in ./content 114 | content // compiled Markdown content 115 | index? // true if is an index page, else false 116 | pages: { name -> page } // for index pages, map of page names -> pages 117 | roots: page[] // parent pages, from the root (/) page down, like breadcrumbs 118 | } 119 | ``` 120 | 121 | The `rss.xml` template is passed something slightly different. It's passed the `site` variable just like others, and then `pages`, a flat list of all the pages in the static site. 122 | 123 | ## Template resolution and rendering rules 124 | 125 | In every directory in `./content`, there are 126 | 127 | - Folders, which are compiled to directories in `./public` of the same name and descendant pages 128 | - Files, which if named `index.md` are compiled to `index.html` and if not, are compiled to `{{ filename }}/index.html` 129 | 130 | Every Sistine page renders once to a single template that is resolved in the following order of decreasing specificity. 131 | 132 | 1. A template at the same directory path and name as the content file, ignoring file extensions. 133 | 2. If an `index.md` file, the template with the name of the directory for which it is the index. For example, `./tpl/foo.md` for `./content/foo/index.md`. Non-index files skip this step. 134 | 3. `index.html` in the same directory path as the content file. 135 | 4. `tpl/index.html`, the root page template. 136 | 137 | If no appropriate option is found after looking in these four places for any given content page, Sistine will generate an error for that page in the CLI output. 138 | 139 | -------------------------------------------------------------------------------- /content/docs/tree.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Directory structure 3 | order: 2 4 | description: Files and folders that live in a Sistine project, and what they all do 5 | --- 6 | 7 | In a typical Sistine project, you'll find a file hierarchy that looks something like this (borrowed from the example site in the [Get started](/start/) page). 8 | 9 | ``` 10 | ├── config.json 11 | ├── content 12 | │   ├── about.md 13 | │   └── index.md 14 | ├── public 15 | │   ├── about 16 | │   │   └── index.html 17 | │   ├── css 18 | │   │   └── main.css 19 | │   ├── index.html 20 | │   └── index.xml 21 | ├── static 22 | │   └── css 23 | │   └── main.css 24 | └── tpl 25 | ├── index.html 26 | ├── parts 27 | │   └── head.html 28 | └── rss.xml 29 | ``` 30 | 31 | The `config.json` file defines the top-level variables for your site, like the site name and domain. You can also add arbitrary JSON data to this configuration file to access it from page templates. 32 | 33 | `./content` contains all _content pages_, authored in Markdown with front matter. Each of these pages gets rendered to exactly one output file in the final static site. Any subdirectories in this content folder are considered distinct "sections" to the site by Sistine, and the index page of each section will have access to all of its children pages. 34 | 35 | `./public` is the destination folder where Sistine will place the output of a build. When deploying a Sistine site, you can deploy the contents of `./public` to a file server. 36 | 37 | `./static` stores all the static (non-content) files of the site. Everything in the static folder will be copied to the public folder before any content rendering takes place. 38 | 39 | `./tpl` stores all page templates for the site, and generally mirrors the structure of the content folder. You can read about template files in the [templating system documentation](/docs/tpl/). 40 | 41 | -------------------------------------------------------------------------------- /content/index.md: -------------------------------------------------------------------------------- 1 | # _Sistine_, the static site engine 2 | 3 | Sistine is a **simple, flexible, productive** static site generator written entirely in [Ink](https://dotink.co/) and built on [Merlot](https://github.com/thesephist/merlot)’s Markdown engine. This demo site is, of course, generated by Sistine itself. 4 | 5 | !html

6 | View source 7 | Get started → 8 |

9 | 10 | ## Features 11 | 12 | Like all my [side projects](https://thesephist.com/projects/), _Sistine_ is ultimately built for me to use and hack on for building my static websites. If there are idiosyncratic features, those appeal to my idiosyncrasies, and if there are missing features, they're probably features I don't need. Sistine is open source for the curious, but not necessarily open-roadmap. With that in mind... 13 | 14 | Sistine tries to cover a lot of creative, expressive ground with a few well-chosen primitives. Among these are simple templating based on a single page type, rich control over page customization with page variables, and an extended Markdown syntax. It's not written in Ink for any particularly good reason, other than that I enjoy writing Ink programs, because I designed the language. 15 | 16 | ### Simple templating 17 | 18 | Unlike other static site generators that work with different types of pages like lists, index pages, and article pages, Sistine knows about exactly one type of page: the ... _page_. 19 | 20 | A page has access to the site configuration and [its own variables](/docs/tpl/), as well as all the pages below it in the content hierarchy. Using these and the templating language, a page can render itself as any appropriate type of layout, from lists of posts by date to a multi-level hierarchy of topics. 21 | 22 | Here's an abridged version of the Sistine template for the [docs](/docs/) page on this site. 23 | 24 | ```html 25 | {{ -- head -- }} 26 | 27 | 28 | {{ -- header -- }} 29 | 30 |
31 | {{ if page.title }}

{{ page.title }}

{{ end }} 32 | {{ page.content }} 33 |
34 | 35 | {{ if page.pages }} 36 |
37 | {{ each page.pages by order asc }} 38 |

{{ title }}

39 |

{{ description }}

40 | {{ end }} 41 |
42 | {{ end }} 43 | 44 | {{ -- footer -- }} 45 | {{ -- scripts -- }} 46 | 47 | ``` 48 | 49 | Here, you can see some of the features of Sistine templates: 50 | 51 | - `{{ -- header -- }}` embeds a _partial template_ at `parts/header.html` into this place in the template. 52 | - `{{ if page.title }}...{{ end }}` lets us include the page title only if it's defined for the page in the content file. 53 | - `{{ each page.pages by order asc }}` loops through all posts in the `page.pages` variable (a list of posts under this page), in ascending order of the `order` page variable. 54 | 55 | You can find the full list and documentation of Sistine's templating features in the [templating](/docs/tpl/) documentation page. 56 | 57 | ### Simple, transparent build process 58 | 59 | Over time, all static site generators accumulate features that make the build process difficult to understand and "see through". By that, I mean that for many static site generators, we can't hold in our minds all the steps that happen conceptually when we run a build. 60 | 61 | Since I was focused on simplicity and hackability (and because Ink is ... slow), I wanted to keep the build process conceptually light with a few, clear steps. This results in a static site generator that gets a lot done with very little complexity. When you run `sistine build`, only five things happen in order. 62 | 63 | 1. Copy over all the static files from `./static` 64 | 2. Read and parse the site configuration defined in `config.json` 65 | 3. Read and parse the "content pages" for the site under `./content` 66 | 4. For each content page... 67 | - Use the page's path to [find a template](/docs/tpl/) and render that page according to the template 68 | - Write that page into a file in `./public` 69 | 5. Render the RSS feed from the `rss.xml` template 70 | 71 | This makes Sistine-generated sites easy to debug, and templates easier to write. 72 | 73 | ### Rich page customization with custom parameters 74 | 75 | Each Sistine page template gets access to a rich set of default variables to render a page, including access to all of its children and parent pages. In addition, each page can easily define (through the [Markdown front matter](/docs/markdown/)) its own set of variables to further customize a page. 76 | 77 | For example, documentation pages on this site have fully customized breadcrumbs, implemented in a simple partial template rather than a separate plugin: 78 | 79 | ```html 80 | {{ if page.roots.1 }} 81 | 91 | {{ end }} 92 | ``` 93 | 94 | ### Out of the box RSS feed support 95 | 96 | I built Sistine primarily to replace other static site generators in my blogging. That means it needed good first-class support for generating site-wide RSS feeds. The `rss.xml` template in `./tpl` gets handed a `pages` list with all pages on the site, and this makes RSS feeds a first class citizen in Sistine projects. 97 | 98 | ## Current progress 99 | 100 | Sistine, like most of my side projects, is a work in progress. It's currently quite stable and featureful enough to build some of my blogs, but not my main website (which uses some custom [Hugo](https://gohugo.io) features like date formatting and custom functions). Sistine is also currently not very fast, because performance was not a goal of the first release. In addition to performance work, some focuses of upcoming releases include 101 | 102 | - Support for blog-specific data formats like reading time, word count, and date/time formatting 103 | - Table of contents (and perhaps sitemap?) support 104 | - Better error messages for mis-parsed and invalid templates 105 | - Syntax highlighting on code blocks 106 | - Support for more Markdown features, blocked on their support in the [Merlot](https://github.com/thesephist/merlot) project 107 | 108 | Given that it's currently quite slow and written in Ink, you probably shouldn't use it for anything important. But if you are interested, and want to ask questions about how it works or what's coming next, feel free to [reach out on Twitter](https://twitter.com/thesephist) or file a [GitHub issue](https://github.com/thesephist/sistine/issues) on the repository. 109 | 110 | -------------------------------------------------------------------------------- /content/start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Get started with Sistine 3 | --- 4 | 5 | Let's build a simple static site with Sistine, from installation to deploy. 6 | 7 | ## Install Sistine 8 | 9 | This guide will show a lot of shell commands in code blocks. Whenever you see a line beginning with a `$`, it represents a command you need to type into your shell (except the leading `$`); other lines represent output, which might not match what you see exactly, but should be close enough to guide you. 10 | 11 | ### Prerequisite: install Ink 12 | 13 | Sistine is written in the [Ink programming language](https://dotink.co/). If you don't have Ink on your system, you'll need to [install it from a release on GitHub](https://dotink.co/docs/overview/#setup-and-installation). Sistine works on Ink releases v0.1.9 and up -- you can check the version of Ink you have by running `ink --version`. 14 | 15 | ``` 16 | $ ink --version 17 | ink v0.1.9 18 | ``` 19 | 20 | ### Get Sistine from source 21 | 22 | Sistine currently doesn't have a simple installation method. We'll have to clone the source repository and run the program from the repository. 23 | 24 | ``` 25 | $ git clone https://github.com/thesephist/sistine 26 | Cloning into 'sistine'... 27 | remote: Enumerating objects: 207, done. 28 | remote: Counting objects: 100% (207/207), done. 29 | remote: Compressing objects: 100% (103/103), done. 30 | Receiving objects: 100% (207/207), 50.59 KiB | 2.66 MiB/s, done. 31 | remote: Total 207 (delta 85), reused 186 (delta 68), pack-reused 0 32 | Resolving deltas: 100% (85/85), done. 33 | ``` 34 | 35 | Once you have Ink installed in your `$PATH` and Sistine cloned, run `./sistine help` to check that it runs correctly. 36 | 37 | ``` 38 | $ cd sistine 39 | 40 | $ ./sistine help 41 | Sistine is a static site generator. 42 | 43 | Quick start 44 | 1. Place your Markdown content in content/ 45 | 2. Add some templates to tpl/ 46 | 3. Add your static assets to static/ 47 | 4. Add a config.json with your site settings 48 | 5. Run `sistine` to build the site 49 | 50 | More documentation at github.com/thesephist/sistine. 51 | 52 | Commands 53 | build build the current site 54 | help show this help message 55 | 56 | Sistine is a project by @thesephist built with Ink. 57 | ``` 58 | 59 | If you see this help menu, you have a copy of Sistine on your system! 60 | 61 | I usually add the Sistine repo to my `$PATH` or symlink a `sistine` command from my path to the executable in the repository, so I can run Sistine from anywhere on my computer with the `sistine` command. 62 | 63 | ## Set up a project 64 | 65 | Let's set up a basic Hello World blog with Sistine in a new directory. 66 | 67 | ``` 68 | # create and enter a new directory for our blog site 69 | $ mkdir my-blog && cd my-blog 70 | ``` 71 | 72 | If you try to run a Sistine build in an empty repository, you'll get this error message: 73 | 74 | ``` 75 | $ sistine build 76 | [sistine] error listing directory contents in dir(), open ./static: no such file or directory 77 | [sistine] could not read the configuration file 78 | ``` 79 | 80 | Sistine looks for static files to copy from `./static` and a configuration file to start building site contents, so let's create those two, then re-run Sistine. 81 | 82 | ``` 83 | $ mkdir static 84 | $ echo '{ "name": "My blog", "origin": "https://example.com" }' > config.json 85 | 86 | $ sistine build 87 | [sistine] could not read content dir "./content" 88 | [sistine] could not read rss template "./tpl/rss.xml" 89 | ``` 90 | 91 | Sistine looks for Markdown files to publish in `./content`, and an (empty) RSS feed template in `./tpl/rss.xml`. Let's create those two, as well as a folder for our templates at `./tpl`. Then we re-run Sistine. 92 | 93 | ``` 94 | $ mkdir content tpl 95 | $ touch ./tpl/rss.xml 96 | 97 | $ sistine 98 | [sistine] rss --( /rss.xml )-> /index.xml 99 | ``` 100 | 101 | This is our first successful build! 102 | 103 | The output from the `sistine` command (which is equivalent to `sistine build`) tells us it rendered an RSS feed with the `/rss.xml` template, to `/index.xml`. We'll see more output like this once we start rendering real pages to our website. 104 | 105 | ### Add a content page 106 | 107 | Let's add a content page for the home page of our site, at `content/index.md`. The file will have some metadata, and contain some Markdown, like this. 108 | 109 | ``` 110 | --- 111 | title: My Sistine blog 112 | date: 2021-05-22 113 | draft: true 114 | --- 115 | 116 | Welcome to my _blog_! 117 | ``` 118 | 119 | We'll also have to add a template for our first, page -- otherwise, Sistine will tell us it couldn't `resolve template for "/index.md"`. Our template will look like this, and be placed in `tpl/index.html`. 120 | 121 | ``` 122 | 123 | 124 | {{ page.title }} 125 | 126 | 127 |

{{ page.title }}

128 | {{ page.content }} 129 | 130 | ``` 131 | 132 | This template represents a minimal HTML page with a title from the page's title field, a header with the page title, and the compiled Markdown contents of this page. 133 | 134 | ### Preview your site 135 | 136 | Let's now build this site, and serve it so we can preview our work so far! Sistine builds the static site into `./public`, which we can serve with Python's built-in HTTP server if you have Python installed. 137 | 138 | ``` 139 | $ sistine 140 | [sistine] rss --( /rss.xml )-> /index.xml 141 | [sistine] /index.md --( /index.html )-> / 142 | 143 | $ python3 -m http.server 10000 --directory ./public 144 | Serving HTTP on :: port 10000 (http://[::]:10000/) ... 145 | ``` 146 | 147 | If you open your browser to `localhost:10000`, you should see your content file rendered through your template! 148 | 149 | ![A browser window with the words "My Sistine blog" and "Welcome to my blog!"](/img/my-blog-screenshot.png) 150 | 151 | ## Customize 152 | 153 | Let's add a little more to this example site by adding a post, and updating some template styles. 154 | 155 | ### Custom templates 156 | 157 | One customization we might make is to show a special warning message on content pages that are marked as `draft: true`. We can do this by modifying our `tpl/index.html` template. 158 | 159 | ``` 160 | 161 | 162 | {{ page.title }} 163 | 164 | 165 |

{{ page.title }}

166 | {{ if page.draft }} 167 | This post is a draft! 168 | {{ end }} 169 | {{ page.content }} 170 | 171 | ``` 172 | 173 | ![A browser window with the words "My Sistine blog", "This post is a draft!", and "Welcome to my blog!"](/img/my-blog-draft-screenshot.png) 174 | 175 | ### Adding pages 176 | 177 | Adding another page, for example an "about" page, is as simple as creating another Markdown file in `./content`. Let's create `./content/about.md` 178 | 179 | ``` 180 | --- 181 | title: About me 182 | date: 2021-05-22 183 | draft: false 184 | --- 185 | 186 | Hi! My name is **Linus**. 187 | ``` 188 | 189 | On the homepage, we may want to show a link to all the other pages on (the top level of) the blog. We can do this by looping through each item in `page.pages` in the `tpl/index.html` template, like this: 190 | 191 | ``` 192 | 193 | 194 | {{ page.title }} 195 | 196 | 197 |

{{ page.title }}

198 | {{ if page.draft }} 199 | This post is a draft! 200 | {{ end }} 201 | {{ page.content }} 202 | 203 | {{ each page.pages by date desc }} 204 |

{{ title }}

205 | {{ end }} 206 | 207 | ``` 208 | 209 | Here, we loop through each page in the children `pages` of the current (home) page, by descending order of dates, and render a link to each post. If we build and refresh the browser, we'll see a link to an about page, which leads us to an about page when we click on it. 210 | 211 | ``` 212 | $ sistine build 213 | [sistine] rss --( /rss.xml )-> /index.xml 214 | [sistine] /index.md --( /index.html )-> / 215 | [sistine] /about.md --( /index.html )-> /about/ 216 | ``` 217 | 218 | ![A browser window with the same site as before, but with an "About me" link](/img/my-blog-about-screenshot.png) 219 | 220 | ### Adding static files 221 | 222 | To add some styles to our blog, we need to create and include a style link tag. Rather than keep adding to our single template, let's split off the `` part of our templates into a _partial template_ so we can reuse it in other templates. To do this, we can create a `./tpl/parts/head.html` like the following 223 | 224 | ``` 225 | 226 | {{ page.title }} 227 | 228 | 229 | ``` 230 | 231 | Back in our main template, we can include this partial template with the partial template syntax, like `{{ -- head -- }}`. 232 | 233 | ``` 234 | 235 | 236 | {{ -- head -- }} 237 | 238 | 239 |

{{ page.title }}

240 | 241 | [...] 242 | ``` 243 | 244 | Lastly, we create a basic stylesheet at `./static/css/main.css` 245 | 246 | ``` 247 | body { 248 | font-family: system-ui, sans-serif; 249 | margin: 2em auto; 250 | max-width: 800px; 251 | width: calc(100% - 24px); 252 | } 253 | ``` 254 | 255 | If we build with `sistine build` once again and refresh our browser, we'll see that our CSS stylesheet is being loaded. 256 | 257 | ![A browser window with the same site as before, but cleaner style](/img/my-blog-styled-screenshot.png) 258 | 259 | ## Deploy & next steps 260 | 261 | Once you have a static site built, you'll want to deploy it somewhere. I've found [Vercel](https://vercel.com), [GitHub pages](https://pages.github.com/), and [Netlify](https://netlify.com) all excellent for hosting static sites and automatically deploying them from within a GitHub repository. 262 | 263 | For more examples on how to build a non-trivial site with Sistine, this documentation website is [open source on GitHub](https://github.com/thesephist/sistine), from the content to the template and stylesheets. And of course, detailed documentation on template directives and the rest of Sistine is available in other pages in the [documentation](/docs/) section of this site. 264 | 265 | -------------------------------------------------------------------------------- /public/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | font-size: 18px; 5 | } 6 | 7 | body { 8 | color: var(--primary-text); 9 | background: var(--primary-bg); 10 | 11 | --font: 'Newsreader', serif; 12 | 13 | /* color variables taken from Merlot */ 14 | --primary-bg: #f9fafb; 15 | --primary-text: #111111; 16 | --secondary-bg: #f3f4f6; 17 | --secondary-text: #9b9b9b; 18 | --hover-bg: #eaebec; 19 | --active-bg: #dcdfe4; 20 | --translucent: rgba(249, 250, 251, .8); 21 | --transparent: rgba(249, 250, 251, 0); 22 | } 23 | 24 | body.dark { 25 | --primary-bg: #2f3437; 26 | --primary-text: #ebebeb; 27 | --secondary-bg: #373c3f; 28 | --secondary-text: #a4a7a9; 29 | --hover-bg: #474c50; 30 | --active-bg: #626569; 31 | --translucent: rgba(47, 52, 55, .8); 32 | --transparent: rgba(47, 52, 55, 0); 33 | } 34 | 35 | body, 36 | input, 37 | textarea, 38 | button { 39 | font-size: 1em; 40 | font-family: var(--font); 41 | } 42 | 43 | a { 44 | color: var(--primary-text); 45 | } 46 | 47 | main { 48 | width: calc(100% - 24px); 49 | max-width: 800px; 50 | margin: 2em auto; 51 | } 52 | 53 | /* buttons */ 54 | 55 | .button-group { 56 | margin-top: 2em; 57 | margin-bottom: 4.5em; 58 | } 59 | 60 | .button { 61 | display: inline-block; 62 | text-decoration: none; 63 | color: var(--primary-text); 64 | border: 2px solid var(--primary-text); 65 | padding: 8px 18px 4px 16px; 66 | margin-right: 8px; 67 | font-style: italic; 68 | box-sizing: border-box; 69 | transition: background .2s; 70 | box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); 71 | } 72 | 73 | .button:hover { 74 | background: var(--hover-bg); 75 | } 76 | 77 | .button:active { 78 | background: var(--active-bg); 79 | } 80 | 81 | .button.filled { 82 | background: var(--primary-text); 83 | color: var(--primary-bg); 84 | transition: background .2s, border-color .2s; 85 | } 86 | 87 | .button.filled:hover { 88 | background: var(--secondary-text); 89 | border-color: var(--secondary-text); 90 | } 91 | 92 | /* article body */ 93 | 94 | article h1, 95 | article h2, 96 | article h3, 97 | article h4 { 98 | margin-top: 2.75rem; 99 | margin-bottom: 1.5rem; 100 | position: relative; 101 | line-height: 1.2em; 102 | } 103 | 104 | article h1 { 105 | margin-top: 2em; 106 | font-size: 2.75em; 107 | font-weight: normal; 108 | } 109 | 110 | article p, 111 | article li { 112 | line-height: 1.5em; 113 | } 114 | 115 | article p img { 116 | max-width: 100%; 117 | } 118 | 119 | article pre, 120 | article code { 121 | background: var(--hover-bg); 122 | border-radius: 4px; 123 | font-family: 'IBM Plex Mono', monospace; 124 | line-height: 1.4rem; 125 | font-size: 0.875rem; /* IBM Plex Mono tends to be large */ 126 | } 127 | 128 | article pre { 129 | overflow-x: auto; 130 | display: flex; 131 | flex-direction: row; 132 | } 133 | 134 | article code { 135 | padding: 1px 2px 2px 2px; 136 | } 137 | 138 | article pre code { 139 | line-height: 1.3em; 140 | padding: 1em; 141 | } 142 | 143 | article blockquote { 144 | margin: 0; 145 | padding-left: 1em; 146 | border-left: 4px solid var(--active-bg); 147 | } 148 | 149 | article hr { 150 | margin: 52px 0; 151 | } 152 | 153 | /* header */ 154 | 155 | header, 156 | nav { 157 | display: flex; 158 | flex-direction: row; 159 | align-items: center; 160 | } 161 | 162 | header { 163 | justify-content: space-between; 164 | } 165 | 166 | nav a { 167 | text-decoration: none; 168 | color: var(--primary-text); 169 | } 170 | 171 | nav a:hover { 172 | text-decoration: underline; 173 | } 174 | 175 | nav.left-nav a { 176 | margin-right: 14px; 177 | } 178 | 179 | nav.right-nav a { 180 | margin-left: 14px; 181 | } 182 | 183 | header nav button { 184 | display: inline; 185 | background: transparent; 186 | padding: 0; 187 | margin: 0; 188 | text-decoration: none; 189 | border: 0; 190 | cursor: pointer; 191 | color: var(--primary-text); 192 | } 193 | 194 | header nav button:hover { 195 | text-decoration: underline; 196 | } 197 | 198 | header .darkModeButton { 199 | font-style: italic; 200 | } 201 | 202 | /* footer */ 203 | 204 | footer { 205 | margin-top: 2.75em; 206 | margin-bottom: 3em; 207 | } 208 | 209 | footer p { 210 | line-height: 1.5em; 211 | } 212 | 213 | footer p, 214 | footer p a { 215 | color: var(--secondary-text); 216 | font-style: italic; 217 | } 218 | 219 | /* breadcrumbs */ 220 | 221 | .breadcrumbs { 222 | margin-top: 4.25em; 223 | margin-bottom: -4.25em; 224 | } 225 | 226 | .breadcrumbs a { 227 | color: var(--secondary-text); 228 | font-style: italic; 229 | text-decoration: none; 230 | } 231 | 232 | .breadcrumbs a:hover { 233 | text-decoration: underline; 234 | } 235 | 236 | .breadcrumb-item::after { 237 | content: ' · '; 238 | margin: 0 .25em; 239 | } 240 | 241 | .breadcrumb-item:last-child::after { 242 | content: none; 243 | } 244 | 245 | /* postlist */ 246 | 247 | .postlist { 248 | margin-top: 2.75em; 249 | } 250 | 251 | .postlist h2 { 252 | margin-top: 1.5em; 253 | } 254 | 255 | .postlist p { 256 | line-height: 1.4em; 257 | } 258 | 259 | a.postlist-link { 260 | text-decoration: none; 261 | } 262 | 263 | a.postlist-link:hover { 264 | text-decoration: underline; 265 | } 266 | 267 | @media only screen and (max-width: 700px) { 268 | article h1 { 269 | font-size: 2.25em; 270 | } 271 | article pre { 272 | font-size: .75em; 273 | } 274 | } 275 | 276 | -------------------------------------------------------------------------------- /public/docs/cli/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sistine CLI | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 53 | 54 | 55 |

Sistine CLI

56 |

Sistine is distributed as a single command-line utility called sistine, written in Ink. You can find a bash script that invokes it in the repository as sistine. The script errors and bails if Ink is not found on the system $PATH.

(If you know what you're doing, you can also run the Sistine CLI manually by running ./src/main.ink with Ink.)

sistine build builds the static site described and configured in the current folder into ./public. If any necessary files or folders cannot be found, it will error. This is the default command that runs when sistine is invoked without any arguments.

sistine help prints the help menu in the CLI and exits.

57 |
58 | 59 | 67 | 68 |
69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /public/docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Documentation | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 42 |

Documentation

43 |

The documentation contains detailed guides and references for how to build static sites with Sistine. To get acquainted and get a feel for Sistine, you may prefer the Get started guide.

44 |
45 | 46 | 47 |
48 | 49 |

Sistine CLI

50 |

A catalog of the Sistine command line commands and options

51 | 52 |

Templating system

53 |

The canonical reference manual for Sistine's templating language and system

54 | 55 |

Directory structure

56 |

Files and folders that live in a Sistine project, and what they all do

57 | 58 |

Sistine Markdown

59 |

The extra features that Sistine's Markdown parser supports, and where it deviates from the norm

60 | 61 |
62 | 63 | 64 | 72 | 73 |
74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /public/docs/markdown/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sistine Markdown | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 53 | 54 | 55 |

Sistine Markdown

56 |

Sistine's Markdown engine comes from the Merlot Markdown editor. As a result, every Markdown feature supported by Merlot is supported in Sistine, and the ones not yet supported in Merlot are not yet here in Sistine, either. Most features of standard Markdown are supported, however, including

Notable features that are not yet supported include

I will probably add them to Merlot (and thus Sistine) as I come to need those features out of these apps.

Front matter in content pages

Every content page in the ./content folder of a Sistine site should be a Markdown file, optionally beginning with front matter. Front matter is a list of key-value pairs that come between two triple-dashed lines. For example, the front matter for this page is something like

---
57 | title: Sistine Markdown
58 | order: 3
59 | description: The extra features ...
60 | ---
61 | 
62 | ( rest of page Markdown ... )

Front matter should always begin and end with three dashes. if a particular page does not need any page variables, it may omit the front matter entirely, and the entire page will be parsed as simple Markdown.

Note that unlike in most popular static site generators, front matter in Sistine do not support the full YAML syntax -- they're simply key: value pairs between triple dashes.

Every key in the front matter of a content page defines a page variable for Sistine templates to use when rendering that page. All variables are parsed and treated as raw strings. In other words the order variable of this page is the string "3".

63 |
64 | 65 | 73 | 74 |
75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /public/docs/tpl/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Templating system | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 53 | 54 | 55 |

Templating system

56 |

Sistine's main job is to take each page from the content directory and render it into a full HTML page using a template. Sistine's page templates are HTML files with extra template directives modeled after Ink's std.format function. The rest of this page details this templating system, and how Sistine finds these templates.

Templating language and directives

Ink's templating language uses double curly braces like {{ these }} to denote special instructions for the templating engine. If you must include double curly braces in your template to be displayed, escape the second brace, like {\{, or use the HTML entity &#123; to denote a curly brace.

Sistine provides the following functions in a template.

Display a variable or property

{{ foo.bar }} resolves to the value of bar in the object foo in the current parameter dictionary. For example, if the page parameters looked like this

{
 57 |     title: 'Hello, World!'
 58 |     foo: {
 59 |         bar: [10, 20, 30]
 60 |         baz: {
 61 |             quux: 'Goodbye!'
 62 |         }
 63 |     }
 64 | }

The following are all valid.

Accessing an undefined or null value will not error -- it will simply render an empty string. This behavior is nice for dealing with optional values, like page.draft which may be usually false.

Conditional if/else expressions

{{ if foo }} X {{ else }} Y {{ end }} renders X if value foo is truthy, Y otherwise. In determining truthiness, the following values and their string forms are considered false, and any other value is considered true:

An idiomatic trick is to check {{ if page.some_list }}...{{ end }} to check whether a list is empty.

Loops through a list or object values

The loop directive is a bit more complex. The full form looks like the following, where the parts in square brackets are optional.

{{ each foo [by bar] [asc|desc] [limit] }}
 65 |     X
 66 | {{ else }}
 67 |     Y
 68 | {{ end }}

If foo is not empty, this directive loops through every value in the list or object foo ordered by each item's property bar and renders X for each value; if the list is empty, this renders Y. The asc|desc declaration determines whether the sort is in ascending or descending order, and limit is the optional, maximum number of items to be looped through, like a limit clause in SQL. They are optional, but a limit must follow an asc/desc declaration. For example, a common format for a reverse-chronological blog post listing page may include

{{ each page.pages by date desc }}
 69 |     {{ -- post-listing -- }}
 70 | {{ else }}
 71 |     <p>No posts yet.</p>
 72 | {{ end }}

Besides the properties that are normally a part of each value in the list, within each {{ each }} section, a template has access to three special variables:

Escaping for HTML

{{ escape foo }} escapes the value of variable foo for HTML. This escapes at least < and & for safe display of HTML code.

Partial template embeds

Partial templates are defined by placing an HTML file into ./tpl/parts. They are referred to by their base filename in other templates. Partial templates can refer to other partial templates, but normal templates cannot refer to other normal templates by their name. For example, to share a common header part across all templates, we may place a header.html into the partial templates folder, then write

{{ -- header -- }}

This will invoke Sistine to search for this partial template in ./tpl/parts/header.html. If one is not found, this directive will be ignored, but you'll see an error message from Sistine in the output.

Page template variables

All page templates are passed a dictionary with values for:

In general, a template begins rendering with these variables, plus any user-defined ones.

site {
 73 |     name
 74 |     origin
 75 |     description
 76 | }
 77 | page {
 78 |     path // URL of the page
 79 |     publicPath // path to file in ./public
 80 |     contentPath // path to file in ./content
 81 |     content // compiled Markdown content
 82 |     index? // true if is an index page, else false
 83 |     pages: { name -> page } // for index pages, map of page names -> pages
 84 |     roots: page[] // parent pages, from the root (/) page down, like breadcrumbs
 85 | }

The rss.xml template is passed something slightly different. It's passed the site variable just like others, and then pages, a flat list of all the pages in the static site.

Template resolution and rendering rules

In every directory in ./content, there are

Every Sistine page renders once to a single template that is resolved in the following order of decreasing specificity.

  1. A template at the same directory path and name as the content file, ignoring file extensions.
  2. If an index.md file, the template with the name of the directory for which it is the index. For example, ./tpl/foo.md for ./content/foo/index.md. Non-index files skip this step.
  3. index.html in the same directory path as the content file.
  4. tpl/index.html, the root page template.

If no appropriate option is found after looking in these four places for any given content page, Sistine will generate an error for that page in the CLI output.

86 |
87 | 88 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /public/docs/tree/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Directory structure | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 53 | 54 | 55 |

Directory structure

56 |

In a typical Sistine project, you'll find a file hierarchy that looks something like this (borrowed from the example site in the Get started page).

├── config.json
57 | ├── content
58 | │   ├── about.md
59 | │   └── index.md
60 | ├── public
61 | │   ├── about
62 | │   │   └── index.html
63 | │   ├── css
64 | │   │   └── main.css
65 | │   ├── index.html
66 | │   └── index.xml
67 | ├── static
68 | │   └── css
69 | │       └── main.css
70 | └── tpl
71 |     ├── index.html
72 |     ├── parts
73 |     │   └── head.html
74 |     └── rss.xml

The config.json file defines the top-level variables for your site, like the site name and domain. You can also add arbitrary JSON data to this configuration file to access it from page templates.

./content contains all content pages, authored in Markdown with front matter. Each of these pages gets rendered to exactly one output file in the final static site. Any subdirectories in this content folder are considered distinct "sections" to the site by Sistine, and the index page of each section will have access to all of its children pages.

./public is the destination folder where Sistine will place the output of a build. When deploying a Sistine site, you can deploy the contents of ./public to a file server.

./static stores all the static (non-content) files of the site. Everything in the static folder will be copied to the public folder before any content rendering takes place.

./tpl stores all page templates for the site, and generally mirrors the structure of the content folder. You can read about template files in the templating system documentation.

75 |
76 | 77 | 85 | 86 |
87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /public/img/my-blog-about-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/public/img/my-blog-about-screenshot.png -------------------------------------------------------------------------------- /public/img/my-blog-draft-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/public/img/my-blog-draft-screenshot.png -------------------------------------------------------------------------------- /public/img/my-blog-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/public/img/my-blog-screenshot.png -------------------------------------------------------------------------------- /public/img/my-blog-styled-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/public/img/my-blog-styled-screenshot.png -------------------------------------------------------------------------------- /public/img/sistine-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/public/img/sistine-screenshot.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 42 | 43 |

Sistine, the static site engine

Sistine is a simple, flexible, productive static site generator written entirely in Ink and built on Merlot’s Markdown engine. This demo site is, of course, generated by Sistine itself.

44 | View source 45 | Get started → 46 |

Features

Like all my side projects, Sistine is ultimately built for me to use and hack on for building my static websites. If there are idiosyncratic features, those appeal to my idiosyncrasies, and if there are missing features, they're probably features I don't need. Sistine is open source for the curious, but not necessarily open-roadmap. With that in mind...

Sistine tries to cover a lot of creative, expressive ground with a few well-chosen primitives. Among these are simple templating based on a single page type, rich control over page customization with page variables, and an extended Markdown syntax. It's not written in Ink for any particularly good reason, other than that I enjoy writing Ink programs, because I designed the language.

Simple templating

Unlike other static site generators that work with different types of pages like lists, index pages, and article pages, Sistine knows about exactly one type of page: the ... page.

A page has access to the site configuration and its own variables, as well as all the pages below it in the content hierarchy. Using these and the templating language, a page can render itself as any appropriate type of layout, from lists of posts by date to a multi-level hierarchy of topics.

Here's an abridged version of the Sistine template for the docs page on this site.

{{ -- head -- }}
47 | 
48 | <body>
49 |   {{ -- header -- }}
50 | 
51 |   <article>
52 |     {{ if page.title }}<h1>{{ page.title }}</h1>{{ end }}
53 |     {{ page.content }}
54 |   </article>
55 | 
56 |   {{ if page.pages }}
57 |   <div>
58 |     {{ each page.pages by order asc }}
59 |     <h2><a href="{{ path }}">{{ title }}</a></h2>
60 |     <p>{{ description }}</p>
61 |     {{ end }}
62 |   </div>
63 |   {{ end }}
64 | 
65 |   {{ -- footer -- }}
66 |   {{ -- scripts -- }}
67 | </body>

Here, you can see some of the features of Sistine templates:

You can find the full list and documentation of Sistine's templating features in the templating documentation page.

Simple, transparent build process

Over time, all static site generators accumulate features that make the build process difficult to understand and "see through". By that, I mean that for many static site generators, we can't hold in our minds all the steps that happen conceptually when we run a build.

Since I was focused on simplicity and hackability (and because Ink is ... slow), I wanted to keep the build process conceptually light with a few, clear steps. This results in a static site generator that gets a lot done with very little complexity. When you run sistine build, only five things happen in order.

  1. Copy over all the static files from ./static
  2. Read and parse the site configuration defined in config.json
  3. Read and parse the "content pages" for the site under ./content
  4. For each content page...
    • Use the page's path to find a template and render that page according to the template
    • Write that page into a file in ./public
  5. Render the RSS feed from the rss.xml template

This makes Sistine-generated sites easy to debug, and templates easier to write.

Rich page customization with custom parameters

Each Sistine page template gets access to a rich set of default variables to render a page, including access to all of its children and parent pages. In addition, each page can easily define (through the Markdown front matter) its own set of variables to further customize a page.

For example, documentation pages on this site have fully customized breadcrumbs, implemented in a simple partial template rather than a separate plugin:

{{ if page.roots.1 }}
68 | <div class="breadcrumbs">
69 |     {{ each page.roots }}
70 |         {{ if i is 0 }}
71 |         {{ else }}
72 |         <span class="breadcrumb-item">
73 |             <a href="{{ path }}">{{ title }}</a>
74 |         </span>
75 |         {{ end }}
76 |     {{ end }}
77 | </div>
78 | {{ end }}

Out of the box RSS feed support

I built Sistine primarily to replace other static site generators in my blogging. That means it needed good first-class support for generating site-wide RSS feeds. The rss.xml template in ./tpl gets handed a pages list with all pages on the site, and this makes RSS feeds a first class citizen in Sistine projects.

Current progress

Sistine, like most of my side projects, is a work in progress. It's currently quite stable and featureful enough to build some of my blogs, but not my main website (which uses some custom Hugo features like date formatting and custom functions). Sistine is also currently not very fast, because performance was not a goal of the first release. In addition to performance work, some focuses of upcoming releases include

Given that it's currently quite slow and written in Ink, you probably shouldn't use it for anything important. But if you are interested, and want to ask questions about how it works or what's coming next, feel free to reach out on Twitter or file a GitHub issue on the repository.

79 |
80 | 81 | 89 | 90 |
91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /public/index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sistine 5 | https://sistine.vercel.app 6 | A simple, flexible, productive static site engine written in Ink 7 | en-us 8 | Sistine - sistine.vercel.app 9 | 10 | 11 | 12 | Sistine 13 | https://sistine.vercel.app/ 14 | / 15 | 16 | 17 | 18 | 19 | Documentation 20 | https://sistine.vercel.app/docs/ 21 | /docs/ 22 | 23 | 24 | 25 | 26 | Sistine CLI 27 | https://sistine.vercel.app/docs/cli/ 28 | /docs/cli/ 29 | A catalog of the Sistine command line commands and options 30 | 31 | 32 | 33 | Sistine Markdown 34 | https://sistine.vercel.app/docs/markdown/ 35 | /docs/markdown/ 36 | The extra features that Sistine's Markdown parser supports, and where it deviates from the norm 37 | 38 | 39 | 40 | Templating system 41 | https://sistine.vercel.app/docs/tpl/ 42 | /docs/tpl/ 43 | The canonical reference manual for Sistine's templating language and system 44 | 45 | 46 | 47 | Directory structure 48 | https://sistine.vercel.app/docs/tree/ 49 | /docs/tree/ 50 | Files and folders that live in a Sistine project, and what they all do 51 | 52 | 53 | 54 | Get started with Sistine 55 | https://sistine.vercel.app/start/ 56 | /start/ 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | // dark mode toggle 2 | 3 | const darkModeButton = document.createElement('button'); 4 | darkModeButton.textContent = 'Dark'; 5 | darkModeButton.classList.add('darkModeButton'); 6 | darkModeButton.addEventListener('click', () => { 7 | darkModeButton.textContent = document.body.classList.contains('dark') ? 'Dark' : 'Light'; 8 | document.body.classList.toggle('dark'); 9 | }); 10 | 11 | const rightNav = document.querySelector('.right-nav'); 12 | rightNav.insertBefore(darkModeButton, rightNav.children[0]); 13 | 14 | -------------------------------------------------------------------------------- /public/start/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Get started with Sistine | Sistine 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | 33 | 36 |
37 | 38 | 39 |
40 | 41 | 42 |

Get started with Sistine

43 |

Let's build a simple static site with Sistine, from installation to deploy.

Install Sistine

This guide will show a lot of shell commands in code blocks. Whenever you see a line beginning with a $, it represents a command you need to type into your shell (except the leading $); other lines represent output, which might not match what you see exactly, but should be close enough to guide you.

Prerequisite: install Ink

Sistine is written in the Ink programming language. If you don't have Ink on your system, you'll need to install it from a release on GitHub. Sistine works on Ink releases v0.1.9 and up -- you can check the version of Ink you have by running ink --version.

$ ink --version
 44 | ink v0.1.9

Get Sistine from source

Sistine currently doesn't have a simple installation method. We'll have to clone the source repository and run the program from the repository.

$ git clone https://github.com/thesephist/sistine
 45 | Cloning into 'sistine'...
 46 | remote: Enumerating objects: 207, done.
 47 | remote: Counting objects: 100% (207/207), done.
 48 | remote: Compressing objects: 100% (103/103), done.
 49 | Receiving objects: 100% (207/207), 50.59 KiB | 2.66 MiB/s, done.
 50 | remote: Total 207 (delta 85), reused 186 (delta 68), pack-reused 0
 51 | Resolving deltas: 100% (85/85), done.

Once you have Ink installed in your $PATH and Sistine cloned, run ./sistine help to check that it runs correctly.

$ cd sistine
 52 | 
 53 | $ ./sistine help
 54 | Sistine is a static site generator.
 55 | 
 56 | Quick start
 57 |     1. Place your Markdown content in content/
 58 |     2. Add some templates to tpl/
 59 |     3. Add your static assets to static/
 60 |     4. Add a config.json with your site settings
 61 |     5. Run `sistine` to build the site
 62 | 
 63 |     More documentation at github.com/thesephist/sistine.
 64 | 
 65 | Commands
 66 |     build    build the current site
 67 |     help    show this help message
 68 | 
 69 | Sistine is a project by @thesephist built with Ink.

If you see this help menu, you have a copy of Sistine on your system!

I usually add the Sistine repo to my $PATH or symlink a sistine command from my path to the executable in the repository, so I can run Sistine from anywhere on my computer with the sistine command.

Set up a project

Let's set up a basic Hello World blog with Sistine in a new directory.

# create and enter a new directory for our blog site
 70 | $ mkdir my-blog && cd my-blog

If you try to run a Sistine build in an empty repository, you'll get this error message:

$ sistine build
 71 | [sistine] error listing directory contents in dir(), open ./static: no such file or directory
 72 | [sistine] could not read the configuration file

Sistine looks for static files to copy from ./static and a configuration file to start building site contents, so let's create those two, then re-run Sistine.

$ mkdir static
 73 | $ echo '{ "name": "My blog", "origin": "https://example.com" }' > config.json
 74 | 
 75 | $ sistine build
 76 | [sistine] could not read content dir "./content"
 77 | [sistine] could not read rss template "./tpl/rss.xml"

Sistine looks for Markdown files to publish in ./content, and an (empty) RSS feed template in ./tpl/rss.xml. Let's create those two, as well as a folder for our templates at ./tpl. Then we re-run Sistine.

$ mkdir content tpl
 78 | $ touch ./tpl/rss.xml
 79 | 
 80 | $ sistine
 81 | [sistine] rss --( /rss.xml )-> /index.xml

This is our first successful build!

The output from the sistine command (which is equivalent to sistine build) tells us it rendered an RSS feed with the /rss.xml template, to /index.xml. We'll see more output like this once we start rendering real pages to our website.

Add a content page

Let's add a content page for the home page of our site, at content/index.md. The file will have some metadata, and contain some Markdown, like this.

---
 82 | title: My Sistine blog
 83 | date: 2021-05-22
 84 | draft: true
 85 | ---
 86 | 
 87 | Welcome to my _blog_!

We'll also have to add a template for our first, page -- otherwise, Sistine will tell us it couldn't resolve template for "/index.md". Our template will look like this, and be placed in tpl/index.html.

<!doctype html>
 88 | 
 89 | <title>{{ page.title }}</title>
 90 | 
 91 | <body>
 92 |     <h1>{{ page.title }}</h1>
 93 |     {{ page.content }}
 94 | </body>

This template represents a minimal HTML page with a title from the page's title field, a header with the page title, and the compiled Markdown contents of this page.

Preview your site

Let's now build this site, and serve it so we can preview our work so far! Sistine builds the static site into ./public, which we can serve with Python's built-in HTTP server if you have Python installed.

$ sistine
 95 | [sistine] rss --( /rss.xml )-> /index.xml
 96 | [sistine] /index.md --( /index.html )-> /
 97 | 
 98 | $ python3 -m http.server 10000 --directory ./public
 99 | Serving HTTP on :: port 10000 (http://[::]:10000/) ...

If you open your browser to localhost:10000, you should see your content file rendered through your template!

A browser window with the words

Customize

Let's add a little more to this example site by adding a post, and updating some template styles.

Custom templates

One customization we might make is to show a special warning message on content pages that are marked as draft: true. We can do this by modifying our tpl/index.html template.

<!doctype html>
100 | 
101 | <title>{{ page.title }}</title>
102 | 
103 | <body>
104 |     <h1>{{ page.title }}</h1>
105 |     {{ if page.draft }}
106 |         <em>This post is a draft!</em>
107 |     {{ end }}
108 |     {{ page.content }}
109 | </body>

A browser window with the words

Adding pages

Adding another page, for example an "about" page, is as simple as creating another Markdown file in ./content. Let's create ./content/about.md

---
110 | title: About me
111 | date: 2021-05-22
112 | draft: false
113 | ---
114 | 
115 | Hi! My name is **Linus**.

On the homepage, we may want to show a link to all the other pages on (the top level of) the blog. We can do this by looping through each item in page.pages in the tpl/index.html template, like this:

<!doctype html>
116 | 
117 | <title>{{ page.title }}</title>
118 | 
119 | <body>
120 |     <h1>{{ page.title }}</h1>
121 |     {{ if page.draft }}
122 |         <em>This post is a draft!</em>
123 |     {{ end }}
124 |     {{ page.content }}
125 | 
126 |     {{ each page.pages by date desc }}
127 |         <p><a href="{{ path }}">{{ title }}</a></p>
128 |     {{ end }}
129 | </body>

Here, we loop through each page in the children pages of the current (home) page, by descending order of dates, and render a link to each post. If we build and refresh the browser, we'll see a link to an about page, which leads us to an about page when we click on it.

$ sistine build
130 | [sistine] rss --( /rss.xml )-> /index.xml
131 | [sistine] /index.md --( /index.html )-> /
132 | [sistine] /about.md --( /index.html )-> /about/

A browser window with the same site as before, but with an

Adding static files

To add some styles to our blog, we need to create and include a style link tag. Rather than keep adding to our single template, let's split off the <head> part of our templates into a partial template so we can reuse it in other templates. To do this, we can create a ./tpl/parts/head.html like the following

<head>
133 |     <title>{{ page.title }}</title>
134 |     <link rel="stylesheet" href="/css/main.css">
135 | </head>

Back in our main template, we can include this partial template with the partial template syntax, like {{ -- head -- }}.

<!doctype html>
136 | 
137 | {{ -- head -- }}
138 | 
139 | <body>
140 |     <h1>{{ page.title }}</h1>
141 | 
142 | [...]

Lastly, we create a basic stylesheet at ./static/css/main.css

body {
143 |     font-family: system-ui, sans-serif;
144 |     margin: 2em auto;
145 |     max-width: 800px;
146 |     width: calc(100% - 24px);
147 | }

If we build with sistine build once again and refresh our browser, we'll see that our CSS stylesheet is being loaded.

A browser window with the same site as before, but cleaner style

Deploy & next steps

Once you have a static site built, you'll want to deploy it somewhere. I've found Vercel, GitHub pages, and Netlify all excellent for hosting static sites and automatically deploying them from within a GitHub repository.

For more examples on how to build a non-trivial site with Sistine, this documentation website is open source on GitHub, from the content to the template and stylesheets. And of course, detailed documentation on template directives and the rest of Sistine is available in other pages in the documentation section of this site.

148 |
149 | 150 | 158 | 159 |
160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /sistine: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Checks if ink is installed in $PATH, and bails if not 4 | if ! command -v ink &> /dev/null 5 | then 6 | echo "[sistine] could not find Ink on your system" 7 | echo "[sistine] install it at dotink.co." 8 | exit 9 | fi 10 | 11 | `dirname "$0"`/src/main.ink $* 12 | -------------------------------------------------------------------------------- /src/build.ink: -------------------------------------------------------------------------------- 1 | ` the sistine build command ` 2 | 3 | std := load('../vendor/std') 4 | str := load('../vendor/str') 5 | json := load('../vendor/json') 6 | 7 | log := std.log 8 | f := std.format 9 | clone := std.clone 10 | slice := std.slice 11 | cat := std.cat 12 | map := std.map 13 | each := std.each 14 | filter := std.filter 15 | readFile := std.readFile 16 | writeFile := std.writeFile 17 | index := str.index 18 | split := str.split 19 | hasSuffix? := str.hasSuffix? 20 | trimPrefix := str.trimPrefix 21 | trimSuffix := str.trimSuffix 22 | trim := str.trim 23 | deJSON := json.de 24 | 25 | Reader := load('../vendor/reader').Reader 26 | 27 | tpl := load('tpl') 28 | compile := tpl.compile 29 | generate := tpl.generate 30 | 31 | md := load('../vendor/md') 32 | compileMarkdown := md.transform 33 | 34 | Newline := char(10) 35 | 36 | ContentDir := './content' 37 | PublicDir := './public' 38 | TplDir := './tpl' 39 | 40 | err := msg => log(f('[sistine] {{0}}', [msg])) 41 | 42 | copyFile := (src, dest) => readFile(src, file => file :: { 43 | () -> err(f('could not copy file "{{0}}"', [src])) 44 | _ -> writeFile(dest, file, res => res :: { 45 | true -> () 46 | _ -> err(f('could not copy file to "{{0}}"', [dest])) 47 | }) 48 | }) 49 | 50 | copyDir := (src, dest) => make(dest, evt => evt.type :: { 51 | 'error' -> err(f('could not copy dir "{{0}}"', [src])) 52 | _ -> dir(src, evt => evt.type :: { 53 | 'error' -> err(evt.message) 54 | _ -> ( 55 | dirs := map(filter(evt.data, ent => ent.dir), ent => ent.name) 56 | files := map(filter(evt.data, ent => ~(ent.dir)), ent => ent.name) 57 | 58 | each(files, fileName => copyFile( 59 | src + '/' + fileName 60 | dest + '/' + fileName 61 | )) 62 | 63 | each(dirs, dirName => copyDir( 64 | src + '/' + dirName 65 | dest + '/' + dirName 66 | )) 67 | ) 68 | }) 69 | }) 70 | 71 | ` compile Markdown, taking into account front matter ` 72 | compileContentPage := file => ( 73 | reader := Reader(split(file, Newline)) 74 | next := reader.next 75 | readUntil := reader.readUntil 76 | 77 | ` fallback content if cannot parse front matter ` 78 | default := () => { 79 | words: len(split(file, ' ')) 80 | content: compileMarkdown(file) 81 | } 82 | 83 | (sub := () => next() :: { 84 | '---' -> frontMatter := readUntil('---') :: { 85 | () -> default() 86 | _ -> ( 87 | next() ` swallow --- ` 88 | 89 | pageParams := {} 90 | each(frontMatter, fmLine => ( 91 | colonIdx := index(fmLine, ':') 92 | key := trim(slice(fmLine, 0, colonIdx), ' ') 93 | value := trim(slice(fmLine, colonIdx + 1, len(fmLine)), ' ') 94 | pageParams.(key) := value 95 | )) 96 | 97 | pageParams.words := len(split(file, ' ')) 98 | pageParams.content := compileMarkdown(cat((reader.readUntilEnd)(), Newline)) 99 | ) 100 | } 101 | _ -> default() 102 | })() 103 | ) 104 | 105 | withParts := cb => dir(TplDir + '/parts', evt => evt.type :: { 106 | 'error' -> cb({}) 107 | _ -> ( 108 | files := evt.data 109 | compiled := {} 110 | each(files, entry => readFile(TplDir + '/parts/' + entry.name, file => ( 111 | file :: { 112 | () -> compiled.trimSuffix(entry.name, '.html') := [] 113 | _ -> compiled.trimSuffix(entry.name, '.html') := compile(file) 114 | } 115 | len(keys(compiled)) :: { 116 | len(files) -> cb(compiled) 117 | } 118 | ))) 119 | ) 120 | }) 121 | 122 | ` transforms contnet file path into canonical content file path ` 123 | toContentPath := contentFilePath => trimPrefix(contentFilePath, ContentDir) 124 | 125 | ` transforms content file path to public html file path ` 126 | toPublicPath := contentFilePath => ( 127 | filePath := slice(contentFilePath, len(ContentDir), len(contentFilePath)) 128 | hasSuffix?(filePath, '.md') :: { 129 | false -> err(f('"{{ 0 }}" is not an .md file', [filePath])) 130 | _ -> hasSuffix?(filePath, '/index.md') :: { 131 | true -> trimSuffix(filePath, '/index.md') + '/index.html' 132 | _ -> trimSuffix(filePath, '.md') + '/index.html' 133 | } 134 | } 135 | ) 136 | 137 | withCompiledContent := cb => ( 138 | Pages := [] 139 | 140 | Jobs := { 141 | dispatched: 0 142 | done: 0 143 | } 144 | dispatchJob := job => ( 145 | Jobs.dispatched := Jobs.dispatched + 1 146 | job(() => ( 147 | Jobs.done := Jobs.done + 1 148 | Jobs.done :: { 149 | Jobs.dispatched -> cb(Pages) 150 | } 151 | )) 152 | ) 153 | 154 | processFile := (filePath, rootPages, cb) => dispatchJob(done => readFile(filePath, file => ( 155 | file :: { 156 | () -> err(f('could not read content file "{{ 0 }}"', [file])) 157 | _ -> ( 158 | publicPath := toPublicPath(filePath) 159 | contentPath := toContentPath(filePath) 160 | page := compileContentPage(file) 161 | 162 | page.path := trimSuffix(publicPath, 'index.html') 163 | page.publicPath := publicPath 164 | page.contentPath := contentPath 165 | page.index? := hasSuffix?(contentPath, '/index.md') 166 | page.pages := {} 167 | page.roots := rootPages 168 | 169 | Pages.len(Pages) := page 170 | cb(page) 171 | ) 172 | } 173 | done() 174 | ))) 175 | 176 | processDir := (dirPath, rootPages, cb) => dispatchJob( 177 | done => dir(dirPath, evt => evt.type :: { 178 | 'error' -> ( 179 | err(f('could not read content dir "{{ 0 }}"', [dirPath])) 180 | done() 181 | ) 182 | _ -> ( 183 | files := filter(evt.data, ent => ~(ent.dir | ent.name.0 = '.')) 184 | 185 | indexFile := filter(files, fileEnt => fileEnt.name = 'index.md').0 186 | pageFiles := filter(files, fileEnt => ~(fileEnt.name = 'index.md')) 187 | 188 | indexFile :: { 189 | () -> () 190 | _ -> processFile(dirPath + '/index.md', rootPages, indexPage => ( 191 | rootPages := clone(rootPages) 192 | rootPages.len(rootPages) := indexPage 193 | 194 | each(pageFiles, fileEnt => processFile( 195 | dirPath + '/' + fileEnt.name 196 | rootPages 197 | page => indexPage.pages.trimSuffix(fileEnt.name, '.md') := page 198 | )) 199 | 200 | dirs := filter(evt.data, ent => ent.dir) 201 | each(dirs, dirEnt => processDir( 202 | dirPath + '/' + dirEnt.name 203 | rootPages 204 | page => indexPage.pages.(dirEnt.name) := page 205 | )) 206 | 207 | cb(indexPage) 208 | )) 209 | } 210 | done() 211 | ) 212 | }) 213 | ) 214 | 215 | processDir(ContentDir, [], page => ()) 216 | ) 217 | 218 | ensureFileDirExistsThen := (fileName, cb) => ( 219 | pathNames := split(fileName, '/') 220 | dirNames := slice(pathNames, 0, len(pathNames) - 1) :: { 221 | [] -> cb() 222 | _ -> make(cat(dirNames, '/'), evt => evt.type :: { 223 | 'error' -> err(f('could not create output dir "{{ 0 }}"', [cat(dirNames, '/')])) 224 | _ -> cb() 225 | }) 226 | } 227 | ) 228 | 229 | writePageToPublic := (path, file) => ( 230 | publicPath := PublicDir + '/' + path 231 | ensureFileDirExistsThen(publicPath, () => ( 232 | writeFile(publicPath, file, res => res :: { 233 | () -> err(f('could not write output file "{{ 0 }}"', [publicPath])) 234 | _ -> () 235 | }) 236 | )) 237 | ) 238 | 239 | resolveTemplatePath := (contentPath, cb) => ( 240 | pathParts := split(contentPath, '/') 241 | searchQueue := filter([ 242 | trimSuffix(contentPath, '.md') + '.html' 243 | hasSuffix?(contentPath, '/index.md') & ~(contentPath = '/index.md') :: { 244 | false -> () 245 | _ -> ( 246 | trimSuffix(contentPath, '/index.md') + '.html' 247 | ) 248 | } 249 | cat(slice(pathParts, 0, len(pathParts) - 1), '/') + '/index.html' 250 | '/index.html' 251 | ], it => ~(it = ())) 252 | 253 | (sub := i => i :: { 254 | len(searchQueue) -> cb(()) 255 | _ -> stat(TplDir + searchQueue.(i), evt => evt.type :: { 256 | 'error' -> ( 257 | err(f('could not stat "{{ 0 }}"', [searchQueue.(i)])) 258 | cb(()) 259 | ) 260 | _ -> evt.data :: { 261 | () -> sub(i + 1) 262 | _ -> cb(searchQueue.(i)) 263 | } 264 | }) 265 | })(0) 266 | ) 267 | 268 | withSiteParams := siteParams => withParts(parts => ( 269 | siteParams.parts := parts 270 | 271 | withCompiledContent(pages => ( 272 | ` generate feeds ` 273 | rssTpl := TplDir + '/rss.xml' 274 | readFile(rssTpl, file => file :: { 275 | () -> err(f('could not read rss template "{{ 0 }}"', [rssTpl])) 276 | _ -> ( 277 | log('[sistine] rss --( /rss.xml )-> /index.xml') 278 | params := (clone(siteParams).pages := pages) 279 | generated := generate(compile(file), params) 280 | writePageToPublic('/index.xml', generated) 281 | ) 282 | }) 283 | 284 | ` generate content pages ` 285 | each(pages, page => ( 286 | params := (clone(siteParams).page := page) 287 | 288 | resolveTemplatePath(page.contentPath, templatePath => templatePath :: { 289 | () -> err(f('could not resolve template for "{{ contentPath }}"', page)) 290 | _ -> readFile(TplDir + templatePath, file => file :: { 291 | () -> err(f('could not read "{{ 0 }}"', [TplDir + templatePath])) 292 | _ -> ( 293 | log(f('[sistine] {{ 0 }} --( {{ 1 }} )-> {{ 2 }}' 294 | [page.contentPath, templatePath, page.path])) 295 | generated := generate(compile(file), params) 296 | writePageToPublic(page.publicPath, generated) 297 | ) 298 | }) 299 | }) 300 | )) 301 | )) 302 | )) 303 | 304 | main := () => ( 305 | ` copy static/ into public/ ` 306 | copyDir('./static', './public') 307 | 308 | readFile('./config.json', file => file :: { 309 | () -> log('[sistine] could not read the configuration file') 310 | _ -> withSiteParams({ 311 | site: deJSON(file) 312 | }) 313 | }) 314 | ) 315 | 316 | -------------------------------------------------------------------------------- /src/help.ink: -------------------------------------------------------------------------------- 1 | ` the sistine help command ` 2 | 3 | std := load('../vendor/std') 4 | 5 | log := std.log 6 | 7 | HelpMessage := 'Sistine is a static site generator. 8 | 9 | Quick start 10 | 1. Place your Markdown content in content/ 11 | 2. Add some templates to tpl/ 12 | 3. Add your static assets to static/ 13 | 4. Add a config.json with your site settings 14 | 5. Run `sistine` to build the site 15 | 16 | More documentation at github.com/thesephist/sistine. 17 | 18 | Commands 19 | build build the current site 20 | help show this help message 21 | 22 | Sistine is a project by @thesephist built with Ink.' 23 | 24 | main := () => log(HelpMessage) 25 | 26 | -------------------------------------------------------------------------------- /src/main.ink: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ink 2 | 3 | std := load('../vendor/std') 4 | json := load('../vendor/json') 5 | cli := load('../vendor/cli') 6 | 7 | ` sistine commands ` 8 | build := load('build').main 9 | help := load('help').main 10 | 11 | given := (cli.parsed)() 12 | given.verb :: { 13 | 'build' -> build() 14 | 'help' -> help() 15 | _ -> build() 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/tpl.ink: -------------------------------------------------------------------------------- 1 | std := load('../vendor/std') 2 | str := load('../vendor/str') 3 | quicksort := load('../vendor/quicksort') 4 | escape := load('../vendor/escape') 5 | 6 | log := std.log 7 | f := std.format 8 | range := std.range 9 | slice := std.slice 10 | cat := std.cat 11 | map := std.map 12 | each := std.each 13 | filter := std.filter 14 | reverse := std.reverse 15 | split := str.split 16 | trim := str.trim 17 | sortBy := quicksort.sortBy 18 | escapeHTML := escape.html 19 | 20 | Reader := load('../vendor/reader').Reader 21 | 22 | iota := ( 23 | S := {i: 0} 24 | () => ( 25 | i := S.i 26 | S.i := S.i + 1 27 | i 28 | ) 29 | ) 30 | 31 | Directive := { 32 | Literal: iota() 33 | Display: iota() 34 | If: iota() 35 | Else: iota() 36 | Each: iota() 37 | End: iota() 38 | Part: iota() 39 | Escape: iota() 40 | } 41 | 42 | parseDirective := directive => ( 43 | parts := filter(map(split(directive, ' '), s => trim(s, ' ')), s => len(s) > 0) 44 | parts.0 :: { 45 | 'if' -> { 46 | type: Directive.If 47 | cond: parts.2 :: { 48 | 'is' -> [parts.1, parts.3] 49 | _ -> parts.1 50 | } 51 | } 52 | 'else' -> { 53 | type: Directive.Else 54 | } 55 | 'each' -> { 56 | type: Directive.Each 57 | values: parts.1 58 | by: parts.3 59 | order: parts.4 :: { 60 | 'desc' -> 'desc' 61 | _ -> 'asc' 62 | } 63 | limit: limit := number(parts.5) :: { 64 | 0 -> () 65 | () -> () 66 | _ -> limit 67 | } 68 | } 69 | 'end' -> { 70 | type: Directive.End 71 | } 72 | '--' -> { 73 | type: Directive.Part 74 | name: parts.1 75 | } 76 | 'escape' -> { 77 | type: Directive.Escape 78 | value: parts.1 79 | } 80 | _ -> { 81 | type: Directive.Display 82 | value: parts.0 83 | } 84 | } 85 | ) 86 | 87 | ` transforms a template string into a list of directives ` 88 | compile := tpl => compileReader(Reader(tpl)) 89 | 90 | compileReader := reader => ( 91 | peek := reader.peek 92 | next := reader.next 93 | readUntil := reader.readUntil 94 | readUntilPrefix := reader.readUntilPrefix 95 | readUntilEnd := reader.readUntilEnd 96 | 97 | parts := [''] 98 | push := s => ( 99 | parts.len(parts) := s 100 | parts.len(parts) := '' 101 | ) 102 | append := s => ( 103 | parts.(len(parts) - 1) := parts.(len(parts) - 1) + s 104 | ) 105 | 106 | (sub := () => c := next() :: { 107 | () -> map(filter(parts, s => len(s) > 0), part => type(part) :: { 108 | 'string' -> { 109 | type: Directive.Literal 110 | value: part 111 | } 112 | _ -> part 113 | }) 114 | '{' -> d := next() :: { 115 | '{' -> directiveString := readUntilPrefix('}}') :: { 116 | () -> sub(append(c + d)) 117 | _ -> ( 118 | next(), next() ` swallow following }} ` 119 | directive := parseDirective(directiveString) 120 | push(directive) 121 | sub() 122 | ) 123 | } 124 | ` allow {\{ to be an escaped {{ ` 125 | '\\' -> sub(append(c)) 126 | ` if template ends with { ` 127 | () -> sub(append(c)) 128 | _ -> sub(append(c + d)) 129 | } 130 | _ -> run := readUntil('{') :: { 131 | () -> sub(append(c + readUntilEnd())) 132 | _ -> sub(append(c + run)) 133 | } 134 | })() 135 | ) 136 | 137 | ` transforms a compiled template (list of directives) into an output string ` 138 | generate := (tpl, params) => generateReader(Reader(tpl), params) 139 | 140 | generateReader := (reader, params) => ( 141 | peek := reader.peek 142 | 143 | (sub := output => directive := peek() :: { 144 | () -> output 145 | ` top level else/end directive signals end of current partial template ` 146 | {type: Directive.Else} -> output 147 | {type: Directive.End} -> output 148 | _ -> sub(output + generateDirective(reader, params)) 149 | })('') 150 | ) 151 | 152 | resolveParamValue := (value, params) => ( 153 | keys := split(value, '.') 154 | (sub := (params, keyIndex) => keyIndex :: { 155 | len(keys) -> params 156 | _ -> ( 157 | key := keys.(keyIndex) 158 | val := params.(key) :: { 159 | () -> () 160 | _ -> sub(val, keyIndex + 1) 161 | } 162 | ) 163 | })(params, 0) 164 | ) 165 | 166 | generateDirective := (reader, params) => ( 167 | next := reader.next 168 | readUntilEnd := reader.readUntilEnd 169 | 170 | directive := next() 171 | directive.type :: { 172 | Directive.Literal -> directive.value 173 | Directive.Display -> resolved := resolveParamValue(directive.value, params) :: { 174 | () -> '' 175 | _ -> string(resolved) 176 | } 177 | Directive.Escape -> resolved := resolveParamValue(directive.value, params) :: { 178 | () -> '' 179 | ` Hugo seems to escape this part of the RSS data twice. I'm not 180 | sure if this is strictly required, so we escape it once for now for 181 | performance reasons. ` 182 | _ -> escapeHTML(string(resolved)) 183 | } 184 | Directive.If -> ( 185 | ifBranch := generateReader(reader, params) 186 | elseBranch := (next() :: { 187 | {type: Directive.Else} -> ( 188 | maybeBranch := generateReader(reader, params) 189 | next() :: { 190 | {type: Directive.End} -> maybeBranch 191 | _ -> ( 192 | log('[sistine] invalid template, could not find {{ end }}') 193 | '' 194 | ) 195 | } 196 | ) 197 | {type: Directive.End} -> '' 198 | () -> ( 199 | log('[sistine] invalid template, could not find {{ else }}') 200 | '' 201 | ) 202 | }) 203 | 204 | directive.cond :: { 205 | [_, _] -> string(resolveParamValue(directive.cond.0, params)) :: { 206 | directive.cond.1 -> ifBranch 207 | _ -> elseBranch 208 | } 209 | ` it may seem better to match against the string()-transformed 210 | version of the resolved param value, but frequently in Sistine 211 | the param value may be an object witih circular references, 212 | which cannot be safely serialized to a string. So we compare 213 | against raw values instead, exhaustively. ` 214 | _ -> resolveParamValue(directive.cond, params) :: { 215 | '' -> elseBranch 216 | 0 -> elseBranch 217 | '0' -> elseBranch 218 | () -> elseBranch 219 | '()' -> elseBranch 220 | {} -> elseBranch 221 | '{}' -> elseBranch 222 | false -> elseBranch 223 | 'false' -> elseBranch 224 | _ -> ifBranch 225 | } 226 | } 227 | ) 228 | Directive.Each -> ( 229 | values := (resolved := resolveParamValue(directive.values, params) :: { 230 | () -> [] 231 | _ -> resolved 232 | }) 233 | 234 | values := (values.0 :: { 235 | () -> map(keys(values), key => values.(key)) 236 | _ -> values 237 | }) 238 | values := (sortKey := directive.by :: { 239 | ` by default, things (usually pages) are sorted by path ` 240 | () -> sortBy(values, item => item.path) 241 | _ -> sortBy(values, item => res := resolveParamValue(sortKey, item) :: { 242 | () -> '' 243 | _ -> res 244 | }) 245 | }) 246 | values := (directive.order :: { 247 | 'asc' -> values 248 | _ -> reverse(values) 249 | }) 250 | values := (directive.limit :: { 251 | () -> values 252 | _ -> slice(values, 0, directive.limit) 253 | }) 254 | 255 | eachBranch := ( 256 | rest := readUntilEnd() 257 | restReader := Reader(rest) 258 | generateReader(restReader, { 259 | parts: params.parts 260 | }) 261 | each(range(0, len((restReader.readUntilEnd)()), 1), reader.back) 262 | 263 | itemParts := map(values, (item, i) => ( 264 | generateReader(Reader(rest), ( 265 | item.i := i 266 | item.'_' := values 267 | item.'*' := params 268 | item.parts := params.parts 269 | )) 270 | )) 271 | cat(itemParts, '') 272 | ) 273 | elseBranch := (next() :: { 274 | {type: Directive.Else} -> ( 275 | maybeBranch := generateReader(reader, params) 276 | next() :: { 277 | {type: Directive.End} -> maybeBranch 278 | _ -> ( 279 | log('[sistine] invalid template, could not find {{ end }}') 280 | '' 281 | ) 282 | } 283 | ) 284 | {type: Directive.End} -> '' 285 | () -> ( 286 | log('[sistine] invalid template, could not find {{ else }}') 287 | '' 288 | ) 289 | }) 290 | 291 | len(values) :: { 292 | 0 -> elseBranch 293 | _ -> eachBranch 294 | } 295 | ) 296 | Directive.Part -> partialTpl := params.parts.(directive.name) :: { 297 | () -> ( 298 | log(f('[sistine] unknown template part "{{ name }}"', directive)) 299 | '' 300 | ) 301 | _ -> generate(partialTpl, params) 302 | } 303 | _ -> ( 304 | log(f('[sistine] unknown directive "{{ 0 }}"', [directive])) 305 | '' 306 | ) 307 | } 308 | ) 309 | 310 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | font-size: 18px; 5 | } 6 | 7 | body { 8 | color: var(--primary-text); 9 | background: var(--primary-bg); 10 | 11 | --font: 'Newsreader', serif; 12 | 13 | /* color variables taken from Merlot */ 14 | --primary-bg: #f9fafb; 15 | --primary-text: #111111; 16 | --secondary-bg: #f3f4f6; 17 | --secondary-text: #9b9b9b; 18 | --hover-bg: #eaebec; 19 | --active-bg: #dcdfe4; 20 | --translucent: rgba(249, 250, 251, .8); 21 | --transparent: rgba(249, 250, 251, 0); 22 | } 23 | 24 | body.dark { 25 | --primary-bg: #2f3437; 26 | --primary-text: #ebebeb; 27 | --secondary-bg: #373c3f; 28 | --secondary-text: #a4a7a9; 29 | --hover-bg: #474c50; 30 | --active-bg: #626569; 31 | --translucent: rgba(47, 52, 55, .8); 32 | --transparent: rgba(47, 52, 55, 0); 33 | } 34 | 35 | body, 36 | input, 37 | textarea, 38 | button { 39 | font-size: 1em; 40 | font-family: var(--font); 41 | } 42 | 43 | a { 44 | color: var(--primary-text); 45 | } 46 | 47 | main { 48 | width: calc(100% - 24px); 49 | max-width: 800px; 50 | margin: 2em auto; 51 | } 52 | 53 | /* buttons */ 54 | 55 | .button-group { 56 | margin-top: 2em; 57 | margin-bottom: 4.5em; 58 | } 59 | 60 | .button { 61 | display: inline-block; 62 | text-decoration: none; 63 | color: var(--primary-text); 64 | border: 2px solid var(--primary-text); 65 | padding: 8px 18px 4px 16px; 66 | margin-right: 8px; 67 | font-style: italic; 68 | box-sizing: border-box; 69 | transition: background .2s; 70 | box-shadow: 0 3px 8px rgba(0, 0, 0, 0.25); 71 | } 72 | 73 | .button:hover { 74 | background: var(--hover-bg); 75 | } 76 | 77 | .button:active { 78 | background: var(--active-bg); 79 | } 80 | 81 | .button.filled { 82 | background: var(--primary-text); 83 | color: var(--primary-bg); 84 | transition: background .2s, border-color .2s; 85 | } 86 | 87 | .button.filled:hover { 88 | background: var(--secondary-text); 89 | border-color: var(--secondary-text); 90 | } 91 | 92 | /* article body */ 93 | 94 | article h1, 95 | article h2, 96 | article h3, 97 | article h4 { 98 | margin-top: 2.75rem; 99 | margin-bottom: 1.5rem; 100 | position: relative; 101 | line-height: 1.2em; 102 | } 103 | 104 | article h1 { 105 | margin-top: 2em; 106 | font-size: 2.75em; 107 | font-weight: normal; 108 | } 109 | 110 | article p, 111 | article li { 112 | line-height: 1.5em; 113 | } 114 | 115 | article p img { 116 | max-width: 100%; 117 | } 118 | 119 | article pre, 120 | article code { 121 | background: var(--hover-bg); 122 | border-radius: 4px; 123 | font-family: 'IBM Plex Mono', monospace; 124 | line-height: 1.4rem; 125 | font-size: 0.875rem; /* IBM Plex Mono tends to be large */ 126 | } 127 | 128 | article pre { 129 | overflow-x: auto; 130 | display: flex; 131 | flex-direction: row; 132 | } 133 | 134 | article code { 135 | padding: 1px 2px 2px 2px; 136 | } 137 | 138 | article pre code { 139 | line-height: 1.3em; 140 | padding: 1em; 141 | } 142 | 143 | article blockquote { 144 | margin: 0; 145 | padding-left: 1em; 146 | border-left: 4px solid var(--active-bg); 147 | } 148 | 149 | article hr { 150 | margin: 52px 0; 151 | } 152 | 153 | /* header */ 154 | 155 | header, 156 | nav { 157 | display: flex; 158 | flex-direction: row; 159 | align-items: center; 160 | } 161 | 162 | header { 163 | justify-content: space-between; 164 | } 165 | 166 | nav a { 167 | text-decoration: none; 168 | color: var(--primary-text); 169 | } 170 | 171 | nav a:hover { 172 | text-decoration: underline; 173 | } 174 | 175 | nav.left-nav a { 176 | margin-right: 14px; 177 | } 178 | 179 | nav.right-nav a { 180 | margin-left: 14px; 181 | } 182 | 183 | header nav button { 184 | display: inline; 185 | background: transparent; 186 | padding: 0; 187 | margin: 0; 188 | text-decoration: none; 189 | border: 0; 190 | cursor: pointer; 191 | color: var(--primary-text); 192 | } 193 | 194 | header nav button:hover { 195 | text-decoration: underline; 196 | } 197 | 198 | header .darkModeButton { 199 | font-style: italic; 200 | } 201 | 202 | /* footer */ 203 | 204 | footer { 205 | margin-top: 2.75em; 206 | margin-bottom: 3em; 207 | } 208 | 209 | footer p { 210 | line-height: 1.5em; 211 | } 212 | 213 | footer p, 214 | footer p a { 215 | color: var(--secondary-text); 216 | font-style: italic; 217 | } 218 | 219 | /* breadcrumbs */ 220 | 221 | .breadcrumbs { 222 | margin-top: 4.25em; 223 | margin-bottom: -4.25em; 224 | } 225 | 226 | .breadcrumbs a { 227 | color: var(--secondary-text); 228 | font-style: italic; 229 | text-decoration: none; 230 | } 231 | 232 | .breadcrumbs a:hover { 233 | text-decoration: underline; 234 | } 235 | 236 | .breadcrumb-item::after { 237 | content: ' · '; 238 | margin: 0 .25em; 239 | } 240 | 241 | .breadcrumb-item:last-child::after { 242 | content: none; 243 | } 244 | 245 | /* postlist */ 246 | 247 | .postlist { 248 | margin-top: 2.75em; 249 | } 250 | 251 | .postlist h2 { 252 | margin-top: 1.5em; 253 | } 254 | 255 | .postlist p { 256 | line-height: 1.4em; 257 | } 258 | 259 | a.postlist-link { 260 | text-decoration: none; 261 | } 262 | 263 | a.postlist-link:hover { 264 | text-decoration: underline; 265 | } 266 | 267 | @media only screen and (max-width: 700px) { 268 | article h1 { 269 | font-size: 2.25em; 270 | } 271 | article pre { 272 | font-size: .75em; 273 | } 274 | } 275 | 276 | -------------------------------------------------------------------------------- /static/img/my-blog-about-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/static/img/my-blog-about-screenshot.png -------------------------------------------------------------------------------- /static/img/my-blog-draft-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/static/img/my-blog-draft-screenshot.png -------------------------------------------------------------------------------- /static/img/my-blog-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/static/img/my-blog-screenshot.png -------------------------------------------------------------------------------- /static/img/my-blog-styled-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/static/img/my-blog-styled-screenshot.png -------------------------------------------------------------------------------- /static/img/sistine-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/sistine/f1520b273c371eaae58ddc3afcd3f8a9f94cabd9/static/img/sistine-screenshot.png -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // dark mode toggle 2 | 3 | const darkModeButton = document.createElement('button'); 4 | darkModeButton.textContent = 'Dark'; 5 | darkModeButton.classList.add('darkModeButton'); 6 | darkModeButton.addEventListener('click', () => { 7 | darkModeButton.textContent = document.body.classList.contains('dark') ? 'Dark' : 'Light'; 8 | document.body.classList.toggle('dark'); 9 | }); 10 | 11 | const rightNav = document.querySelector('.right-nav'); 12 | rightNav.insertBefore(darkModeButton, rightNav.children[0]); 13 | 14 | -------------------------------------------------------------------------------- /test/main.ink: -------------------------------------------------------------------------------- 1 | `` runMarkdownTests := load('md').run 2 | `` runReaderTests := load('reader').run 3 | 4 | s := (load('../vendor/suite').suite)( 5 | 'Sistine test suite' 6 | ) 7 | 8 | `` runMarkdownTests(s.mark, s.test) 9 | `` runReaderTests(s.mark, s.test) 10 | 11 | (s.end)() 12 | 13 | -------------------------------------------------------------------------------- /tpl/docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ -- head -- }} 4 | 5 | 6 |
7 | {{ -- header -- }} 8 | 9 |
10 | {{ -- breadcrumbs -- }} 11 | {{ if page.title }}

{{ page.title }}

{{ end }} 12 | {{ page.content }} 13 |
14 | 15 | {{ if page.pages }} 16 |
17 | {{ each page.pages by order asc }} 18 |

{{ title }}

19 |

{{ description }}

20 | {{ end }} 21 |
22 | {{ end }} 23 | 24 | {{ -- footer -- }} 25 |
26 | 27 | {{ -- scripts -- }} 28 | 29 | -------------------------------------------------------------------------------- /tpl/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ -- head -- }} 4 | 5 | 6 |
7 | {{ -- header -- }} 8 | 9 |
10 | {{ -- breadcrumbs -- }} 11 | {{ if page.title }}

{{ page.title }}

{{ end }} 12 | {{ page.content }} 13 |
14 | 15 | {{ -- footer -- }} 16 |
17 | 18 | {{ -- scripts -- }} 19 | 20 | -------------------------------------------------------------------------------- /tpl/parts/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | {{ if page.roots.1 }} 2 | 12 | {{ end }} 13 | -------------------------------------------------------------------------------- /tpl/parts/footer.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /tpl/parts/head.html: -------------------------------------------------------------------------------- 1 | 2 | {{ if page.title }}{{ page.title }} | {{ end }}Sistine 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tpl/parts/header.html: -------------------------------------------------------------------------------- 1 |
2 | 7 | 10 |
11 | -------------------------------------------------------------------------------- /tpl/parts/scripts.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tpl/rss.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ site.name }} 5 | {{ site.origin }} 6 | {{ site.description }} 7 | en-us 8 | Sistine - sistine.vercel.app 9 | 10 | {{ each pages }} 11 | 12 | {{ if title }}{{ title }}{{ else }}{{ *.site.name }}{{ end }} 13 | {{ *.site.origin }}{{ path }} 14 | {{ path }} 15 | {{ escape description }} 16 | 17 | {{ end }} 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /vendor/cli.ink: -------------------------------------------------------------------------------- 1 | ` command-line interface abstractions 2 | for [cmd] [verb] [options] form` 3 | 4 | std := load('../vendor/std') 5 | str := load('../vendor/str') 6 | 7 | each := std.each 8 | slice := std.slice 9 | hasPrefix? := str.hasPrefix? 10 | 11 | maybeOpt := part => true :: { 12 | hasPrefix?(part, '--') -> slice(part, 2, len(part)) 13 | hasPrefix?(part, '-') -> slice(part, 1, len(part)) 14 | _ -> () 15 | } 16 | 17 | ` 18 | Supports: 19 | -opt val 20 | --opt val 21 | -opt=val 22 | --opt val 23 | all other values are considered args 24 | ` 25 | parsed := () => ( 26 | as := args() 27 | 28 | verb := as.2 29 | rest := slice(as, 3, len(as)) 30 | 31 | opts := {} 32 | args := [] 33 | 34 | s := { 35 | lastOpt: () 36 | onlyArgs: false 37 | } 38 | each(rest, part => [maybeOpt(part), s.lastOpt] :: { 39 | [(), ()] -> ( 40 | ` not opt, no prev opt ` 41 | args.len(args) := part 42 | ) 43 | [(), _] -> ( 44 | ` not opt, prev opt exists ` 45 | opts.(s.lastOpt) := part 46 | s.lastOpt := () 47 | ) 48 | [_, ()] -> ( 49 | ` is opt, no prev opt ` 50 | s.lastOpt := maybeOpt(part) 51 | ) 52 | _ -> ( 53 | ` is opt, prev opt exists ` 54 | opts.(s.lastOpt) := true 55 | s.lastOpt := maybeOpt(part) 56 | ) 57 | }) 58 | 59 | s.lastOpt :: { 60 | () -> () 61 | _ -> opts.(s.lastOpt) := true 62 | } 63 | 64 | { 65 | verb: verb 66 | opts: opts 67 | args: args 68 | } 69 | ) 70 | -------------------------------------------------------------------------------- /vendor/escape.ink: -------------------------------------------------------------------------------- 1 | ` escaping various formats ` 2 | 3 | str := load('../vendor/str') 4 | replace := str.replace 5 | 6 | html := s => ( 7 | s := replace(s, '&', '&') 8 | s := replace(s, '<', '<') 9 | ) 10 | -------------------------------------------------------------------------------- /vendor/json.ink: -------------------------------------------------------------------------------- 1 | ` JSON serde ` 2 | 3 | std := load('std') 4 | str := load('str') 5 | 6 | map := std.map 7 | cat := std.cat 8 | 9 | ws? := str.ws? 10 | digit? := str.digit? 11 | 12 | ` string escape '"' ` 13 | esc := c => point(c) :: { 14 | 9 -> '\\t' 15 | 10 -> '\\n' 16 | 13 -> '\\r' 17 | 34 -> '\\"' 18 | 92 -> '\\\\' 19 | _ -> c 20 | } 21 | escape := s => ( 22 | max := len(s) 23 | (sub := (i, acc) => i :: { 24 | max -> acc 25 | _ -> sub(i + 1, acc + esc(s.(i))) 26 | })(0, '') 27 | ) 28 | 29 | ` composite to JSON string ` 30 | ser := c => type(c) :: { 31 | '()' -> 'null' 32 | 'string' -> '"' + escape(c) + '"' 33 | 'number' -> string(c) 34 | 'boolean' -> string(c) 35 | ` do not serialize functions ` 36 | 'function' -> 'null' 37 | 'composite' -> '{' + cat(map(keys(c), k => '"' + k + '":' + ser(c.(k))), ',') + '}' 38 | } 39 | 40 | ` is this character a numeral digit or .? ` 41 | num? := c => c :: { 42 | '' -> false 43 | '.' -> true 44 | _ -> digit?(c) 45 | } 46 | 47 | ` reader implementation with internal state for deserialization ` 48 | reader := s => ( 49 | state := { 50 | idx: 0 51 | ` has there been a parse error? ` 52 | err?: false 53 | } 54 | 55 | next := () => ( 56 | state.idx := state.idx + 1 57 | c := s.(state.idx - 1) :: { 58 | () -> '' 59 | _ -> c 60 | } 61 | ) 62 | 63 | peek := () => c := s.(state.idx) :: { 64 | () -> '' 65 | _ -> c 66 | } 67 | 68 | { 69 | next: next 70 | peek: peek 71 | ` fast-forward through whitespace ` 72 | ff: () => (sub := () => ws?(peek()) :: { 73 | true -> ( 74 | state.idx := state.idx + 1 75 | sub() 76 | ) 77 | })() 78 | done?: () => ~(state.idx < len(s)) 79 | err: () => state.err? := true 80 | err?: () => state.err? 81 | } 82 | ) 83 | 84 | ` deserialize null ` 85 | deNull := r => ( 86 | n := r.next 87 | n() + n() + n() + n() :: { 88 | 'null' -> () 89 | _ -> (r.err)() 90 | } 91 | ) 92 | 93 | ` deserialize string ` 94 | deString := r => ( 95 | n := r.next 96 | p := r.peek 97 | 98 | ` known to be a '"' ` 99 | n() 100 | 101 | (sub := acc => p() :: { 102 | '' -> ( 103 | (r.err)() 104 | () 105 | ) 106 | '\\' -> ( 107 | ` eat backslash ` 108 | n() 109 | sub(acc + (c := n() :: { 110 | 't' -> char(9) 111 | 'n' -> char(10) 112 | 'r' -> char(13) 113 | '"' -> '"' 114 | _ -> c 115 | })) 116 | ) 117 | '"' -> ( 118 | n() 119 | acc 120 | ) 121 | _ -> sub(acc + n()) 122 | })('') 123 | ) 124 | 125 | ` deserialize number ` 126 | deNumber := r => ( 127 | n := r.next 128 | p := r.peek 129 | state := { 130 | ` have we seen a '.' yet? ` 131 | negate?: false 132 | decimal?: false 133 | } 134 | 135 | p() :: { 136 | '-' -> ( 137 | n() 138 | state.negate? := true 139 | ) 140 | } 141 | 142 | result := (sub := acc => num?(p()) :: { 143 | true -> p() :: { 144 | '.' -> state.decimal? :: { 145 | true -> (r.err)() 146 | false -> ( 147 | state.decimal? := true 148 | sub(acc + n()) 149 | ) 150 | } 151 | _ -> sub(acc + n()) 152 | } 153 | false -> acc 154 | })('') 155 | 156 | state.negate? :: { 157 | false -> number(result) 158 | true -> ~number(result) 159 | } 160 | ) 161 | 162 | ` deserialize boolean ` 163 | deTrue := r => ( 164 | n := r.next 165 | n() + n() + n() + n() :: { 166 | 'true' -> true 167 | _ -> (r.err)() 168 | } 169 | ) 170 | deFalse := r => ( 171 | n := r.next 172 | n() + n() + n() + n() + n() :: { 173 | 'false' -> false 174 | _ -> (r.err)() 175 | } 176 | ) 177 | 178 | ` deserialize list ` 179 | deList := r => ( 180 | n := r.next 181 | p := r.peek 182 | ff := r.ff 183 | state := { 184 | idx: 0 185 | } 186 | 187 | ` known to be a '[' ` 188 | n() 189 | ff() 190 | 191 | (sub := acc => (r.err?)() :: { 192 | true -> () 193 | false -> p() :: { 194 | '' -> ( 195 | (r.err)() 196 | () 197 | ) 198 | ']' -> ( 199 | n() 200 | acc 201 | ) 202 | _ -> ( 203 | acc.(state.idx) := der(r) 204 | state.idx := state.idx + 1 205 | 206 | ff() 207 | p() :: { 208 | ',' -> n() 209 | } 210 | 211 | ff() 212 | sub(acc) 213 | ) 214 | } 215 | })([]) 216 | ) 217 | 218 | ` deserialize composite ` 219 | deComp := r => ( 220 | n := r.next 221 | p := r.peek 222 | ff := r.ff 223 | 224 | ` known to be a '{' ` 225 | n() 226 | ff() 227 | 228 | (sub := acc => (r.err?)() :: { 229 | true -> () 230 | false -> p() :: { 231 | '' -> (r.err)() 232 | '}' -> ( 233 | n() 234 | acc 235 | ) 236 | _ -> ( 237 | key := deString(r) 238 | 239 | (r.err?)() :: { 240 | false -> ( 241 | ff() 242 | p() :: { 243 | ':' -> n() 244 | } 245 | 246 | ff() 247 | val := der(r) 248 | 249 | (r.err?)() :: { 250 | false -> ( 251 | ff() 252 | p() :: { 253 | ',' -> n() 254 | } 255 | 256 | ff() 257 | acc.(key) := val 258 | sub(acc) 259 | ) 260 | } 261 | ) 262 | } 263 | ) 264 | } 265 | })({}) 266 | ) 267 | 268 | ` JSON string in reader to composite ` 269 | der := r => ( 270 | ` trim preceding whitespace ` 271 | (r.ff)() 272 | 273 | result := ((r.peek)() :: { 274 | 'n' -> deNull(r) 275 | '"' -> deString(r) 276 | 't' -> deTrue(r) 277 | 'f' -> deFalse(r) 278 | '[' -> deList(r) 279 | '{' -> deComp(r) 280 | _ -> deNumber(r) 281 | }) 282 | 283 | ` if there was a parse error, just return null result ` 284 | (r.err?)() :: { 285 | true -> () 286 | false -> result 287 | } 288 | ) 289 | 290 | ` JSON string to composite ` 291 | de := s => der(reader(s)) 292 | -------------------------------------------------------------------------------- /vendor/md.ink: -------------------------------------------------------------------------------- 1 | std := load('../vendor/std') 2 | str := load('../vendor/str') 3 | 4 | cat := std.cat 5 | slice := std.slice 6 | map := std.map 7 | filter := std.filter 8 | reduce := std.reduce 9 | each := std.each 10 | every := std.every 11 | append := std.append 12 | f := std.format 13 | ws? := str.ws? 14 | digit? := str.digit? 15 | letter? := str.letter? 16 | index := str.index 17 | hasPrefix? := str.hasPrefix? 18 | hasSuffix? := str.hasSuffix? 19 | trimPrefix := str.trimPrefix 20 | replace := str.replace 21 | split := str.split 22 | 23 | Reader := load('reader').Reader 24 | 25 | Newline := char(10) 26 | Tab := char(9) 27 | 28 | ` Constant Node represents all possible types of AST nodes in the Merlot 29 | Markdown abstract syntax tree. This also maps node types to their HTML tag 30 | names. ` 31 | Node := { 32 | P: 'p' 33 | Em: 'em' 34 | Strong: 'strong' 35 | Strike: 'strike' 36 | A: 'a' 37 | H1: 'h1' 38 | H2: 'h2' 39 | H3: 'h3' 40 | H4: 'h4' 41 | H5: 'h5' 42 | H6: 'h6' 43 | Quote: 'blockquote' 44 | Img: 'img' 45 | Pre: 'pre' 46 | Code: 'code' 47 | UList: 'ul' 48 | OList: 'ol' 49 | Item: 'li' 50 | Checkbox: 'checkbox' 51 | Br: 'br' 52 | Hr: 'hr' 53 | Empty: '-empty' 54 | RawHTML: '-raw-html' 55 | } 56 | 57 | ` wordChar? reports whether a given character is a "word character", i.e. 58 | whether it is a part of a normal word. It intends to be Unicode-aware and is 59 | used for text token disambiguation. ` 60 | wordChar? := c => digit?(c) | letter?(c) | point(c) > 127 61 | 62 | ` tokenizeText tokenizes a paragraph or paragraph-like Markdown text (like 63 | headers) into a token stream. 64 | 65 | This function encapsulates all disambiguation rules for e.g. parens inside A 66 | (link) tag parens, undescores inside words, and escaped special characters with 67 | backslashes. ` 68 | tokenizeText := line => ( 69 | reader := Reader(line) 70 | 71 | peek := reader.peek 72 | next := reader.next 73 | 74 | tokens := [''] 75 | push := tok => ( 76 | tokens.len(tokens) := tok 77 | tokens.len(tokens) := '' 78 | ) 79 | append := suffix => 80 | tokens.(len(tokens) - 1) := tokens.(len(tokens) - 1) + suffix 81 | 82 | (sub := () => c := next() :: { 83 | () -> () 84 | ` italics & bold ` 85 | '_' -> ( 86 | peek() :: { 87 | '_' -> ( 88 | next() 89 | push('__') 90 | ) 91 | _ -> push('_') 92 | } 93 | sub() 94 | ) 95 | '*' -> ( 96 | peek() :: { 97 | '*' -> ( 98 | next() 99 | push('**') 100 | ) 101 | _ -> push('*') 102 | } 103 | sub() 104 | ) 105 | ` \ escapes any character ` 106 | '\\' -> d := next() :: { 107 | () -> () 108 | _ -> sub(append(d)) 109 | } 110 | ` code snippet ` 111 | '`' -> sub(push('`')) 112 | ` strike out ` 113 | '~' -> sub(push('~')) 114 | '!' -> sub(push('!')) 115 | '[' -> sub(push('[')) 116 | ']' -> sub(push(']')) 117 | '(' -> sub(push('(')) 118 | ')' -> sub(push(')')) 119 | _ -> sub(append(c)) 120 | })() 121 | 122 | filter(tokens, tok => len(tok) > 0) 123 | ) 124 | 125 | ` unifyTextNodes normalizes a Markdown AST so that runs of consecutive plain 126 | text nodes (strings) are combined into single plain text nodes. ` 127 | unifyTextNodes := (nodes, joiner) => reduce(nodes, (acc, child) => type(child) :: { 128 | 'string' -> type(last := acc.(len(acc) - 1)) :: { 129 | 'string' -> acc.(len(acc) - 1) := last + joiner + child 130 | _ -> acc.len(acc) := child 131 | } 132 | _ -> acc.len(acc) := (child.children :: { 133 | () -> child 134 | _ -> child.children := unifyTextNodes(child.children, joiner) 135 | }) 136 | }, []) 137 | 138 | ` parseText takes a stream of inline tokens from a header or paragraph section 139 | of a Markdown document and produces a list of inline AST nodes to be included 140 | in a Node.H or Node.P. ` 141 | parseText := tokens => ( 142 | reader := Reader(tokens) 143 | 144 | peek := reader.peek 145 | next := reader.next 146 | readUntil := reader.readUntil 147 | readUntilMatchingDelim := reader.readUntilMatchingDelim 148 | 149 | handleDelimitedRange := (tok, tag, nodes, sub) => range := readUntil(tok) :: { 150 | () -> sub(nodes.len(nodes) := tok) 151 | _ -> ( 152 | next() ` swallow trailing tok ` 153 | nodes.len(nodes) := { 154 | tag: tag 155 | children: parseText(range) 156 | } 157 | sub(nodes) 158 | ) 159 | } 160 | 161 | nodes := (sub := nodes => tok := next() :: { 162 | () -> nodes 163 | '_' -> handleDelimitedRange('_', Node.Em, nodes, sub) 164 | '__' -> handleDelimitedRange('__', Node.Strong, nodes, sub) 165 | '*' -> handleDelimitedRange('*', Node.Em, nodes, sub) 166 | '**' -> handleDelimitedRange('**', Node.Strong, nodes, sub) 167 | '`' -> handleDelimitedRange('`', Node.Code, nodes, sub) 168 | '~' -> handleDelimitedRange('~', Node.Strike, nodes, sub) 169 | '[' -> range := readUntilMatchingDelim('[') :: { 170 | () -> sub(nodes.len(nodes) := tok) 171 | ['x'] -> ( 172 | next() ` swallow matching ] ` 173 | sub(nodes.len(nodes) := { 174 | tag: Node.Checkbox 175 | checked: true 176 | }) 177 | ) 178 | [' '] -> ( 179 | next() ` swallow matching ] ` 180 | sub(nodes.len(nodes) := { 181 | tag: Node.Checkbox 182 | checked: false 183 | }) 184 | ) 185 | _ -> c := (next() ` swallow matching ] `, next()) :: { 186 | '(' -> urlRange := readUntilMatchingDelim(c) :: { 187 | () -> sub(nodes.len(nodes) := tok + cat(range, '') + ']' + c) 188 | _ -> ( 189 | next() ` swallow matching ) ` 190 | link := { 191 | tag: Node.A 192 | href: cat(urlRange, '') 193 | children: parseText(range) 194 | } 195 | sub(nodes.len(nodes) := link) 196 | ) 197 | } 198 | () -> sub(nodes.len(nodes) := tok + cat(range, '') + ']') 199 | _ -> sub(nodes.len(nodes) := tok + cat(range, '') + ']' + c) 200 | } 201 | } 202 | '!' -> peek() :: { 203 | '[' -> range := (next(), readUntilMatchingDelim('[')) :: { 204 | () -> sub(nodes.len(nodes) := tok + '[') 205 | ['x'] -> ( 206 | next() ` swallow matching ] ` 207 | nodes.len(nodes) := tok 208 | sub(nodes.len(nodes) := { 209 | tag: Node.Checkbox 210 | checked: true 211 | }) 212 | ) 213 | [' '] -> ( 214 | next() ` swallow matching ] ` 215 | nodes.len(nodes) := tok 216 | sub(nodes.len(nodes) := { 217 | tag: Node.Checkbox 218 | checked: false 219 | }) 220 | ) 221 | _ -> c := (next() ` swallow matching ] `, next()) :: { 222 | '(' -> urlRange := readUntilMatchingDelim(c) :: { 223 | () -> sub(nodes.len(nodes) := tok + '[' + cat(range, '') + ']' + c) 224 | _ -> ( 225 | next() ` swallow matching ) ` 226 | img := { 227 | tag: Node.Img 228 | alt: cat(range, '') 229 | src: cat(urlRange, '') 230 | } 231 | sub(nodes.len(nodes) := img) 232 | ) 233 | } 234 | () -> sub(nodes.len(nodes) := tok + '[' + cat(range, '') + ']') 235 | _ -> sub(nodes.len(nodes) := tok + '[' + cat(range, '') + ']' + c) 236 | } 237 | } 238 | _ -> sub(nodes.len(nodes) := tok) 239 | } 240 | _ -> sub(nodes.len(nodes) := tok) 241 | })([]) 242 | 243 | unifyTextNodes(nodes, '') 244 | ) 245 | 246 | uListItemLine? := line => line :: { 247 | () -> false 248 | _ -> hasPrefix?(trimPrefix(trimPrefix(line, ' '), Tab), '- ') 249 | } 250 | 251 | oListItemLine? := line => line :: { 252 | () -> false 253 | _ -> ( 254 | trimmedStart := trimPrefix(trimPrefix(line, ' '), Tab) 255 | dotIndex := index(trimmedStart, '. ') :: { 256 | ~1 -> false 257 | 0 -> false 258 | _ -> every(map(slice(trimmedStart, 0, dotIndex), digit?)) 259 | } 260 | ) 261 | } 262 | 263 | listItemLine? := line => uListItemLine?(line) | oListItemLine?(line) 264 | 265 | trimUListGetLevel := reader => ( 266 | level := len((reader.readUntil)('-')) 267 | each('- ', reader.next) 268 | level 269 | ) 270 | 271 | trimOListGetLevel := reader => ( 272 | peek := reader.peek 273 | next := reader.next 274 | 275 | ` read while whitespace ` 276 | level := (sub := i => ws?(peek()) :: { 277 | true -> ( 278 | next() 279 | sub(i + 1) 280 | ) 281 | false -> i 282 | })(0) 283 | 284 | ` swallow until dot ` 285 | (reader.readUntil)('.') 286 | next() 287 | 288 | ` if space after dot, swallow it ` 289 | (reader.peek)() :: { 290 | ' ' -> next() 291 | } 292 | level 293 | ) 294 | 295 | ` lineNodeType reports the node type of a particular markdown line for parsing. ` 296 | lineNodeType := line => true :: { 297 | (line = ()) -> () 298 | (line = '') -> Node.Empty 299 | hasPrefix?(line, '# ') -> Node.H1 300 | hasPrefix?(line, '## ') -> Node.H2 301 | hasPrefix?(line, '### ') -> Node.H3 302 | hasPrefix?(line, '#### ') -> Node.H4 303 | hasPrefix?(line, '##### ') -> Node.H5 304 | hasPrefix?(line, '###### ') -> Node.H6 305 | hasPrefix?(line, '>') -> Node.Quote 306 | hasPrefix?(line, '```') -> Node.Pre 307 | hasPrefix?(line, '---') -> Node.Hr 308 | hasPrefix?(line, '***') -> Node.Hr 309 | hasPrefix?(line, '!html ') -> Node.RawHTML 310 | uListItemLine?(line) -> Node.UList 311 | oListItemLine?(line) -> Node.OList 312 | _ -> Node.P 313 | } 314 | 315 | ` parse parses a byte string of Markdown formatted text into a Markdown AST, by 316 | looking at each line and either changing internal state if the line is a 317 | special line like a code fence or a raw HTML literal, or calling tokenizeText() 318 | if the line is a raw paragraph or header. ` 319 | parse := text => parseDoc(Reader(split(text, Newline))) 320 | 321 | ` parseDoc parses a Markdown docment from a line Reader. This allows 322 | sub-sections of the document to re-use this document parser to parse e.g. 323 | quoted sections that should be parsed as an independent subsection by providing 324 | a line Reader interface. ` 325 | parseDoc := lineReader => ( 326 | peek := lineReader.peek 327 | next := lineReader.next 328 | 329 | (sub := doc => nodeType := lineNodeType(peek()) :: { 330 | Node.H1 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 331 | Node.H2 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 332 | Node.H3 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 333 | Node.H4 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 334 | Node.H5 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 335 | Node.H6 -> sub(doc.len(doc) := parseHeader(nodeType, lineReader)) 336 | Node.Quote -> sub(doc.len(doc) := parseBlockQuote(lineReader)) 337 | Node.Pre -> sub(doc.len(doc) := parseCodeBlock(lineReader)) 338 | Node.UList -> sub(doc.len(doc) := parseList(lineReader, nodeType)) 339 | Node.OList -> sub(doc.len(doc) := parseList(lineReader, nodeType)) 340 | Node.RawHTML -> sub(doc.len(doc) := parseRawHTML(lineReader)) 341 | Node.P -> sub(doc.len(doc) := parseParagraph(lineReader)) 342 | Node.Hr -> ( 343 | next() 344 | sub(doc.len(doc) := {tag: Node.Hr}) 345 | ) 346 | Node.Empty -> ( 347 | next() 348 | sub(doc) 349 | ) 350 | _ -> doc 351 | })([]) 352 | ) 353 | 354 | parseHeader := (nodeType, lineReader) => ( 355 | line := (lineReader.next)() 356 | reader := Reader(line) 357 | (reader.readUntil)(' ') 358 | (reader.next)() 359 | 360 | text := (reader.readUntilEnd)() 361 | { 362 | tag: nodeType 363 | children: parseText(tokenizeText(text)) 364 | } 365 | ) 366 | 367 | parseBlockQuote := lineReader => ( 368 | peek := lineReader.peek 369 | next := lineReader.next 370 | 371 | ` A piece of a document inside a quoted block needs to be parsed as if it 372 | were its own document. The BlockQuotedLineReader provides a line Reader 373 | that masquerades as a document reader to parseDoc. ` 374 | BlockQuotedLineReader := lineReader => ( 375 | 376 | returnIfQuoted := line => lineNodeType(line) :: { 377 | Node.Quote -> slice(line, 1, len(line)) 378 | _ -> () 379 | } 380 | 381 | peek := () => returnIfQuoted((lineReader.peek)()) 382 | last := () => returnIfQuoted((lineReader.last)()) 383 | back := () => (lineReader.back)() 384 | next := () => lineNodeType((lineReader.peek)()) :: { 385 | Node.Quote -> trimPrefix((lineReader.next)(), '>') 386 | _ -> () 387 | } 388 | expect? := () => () `` NOTE: not implemented 389 | readUntil := c => ( 390 | lines := (lineReader.readUntil)('>' + c) 391 | map(lines, line => slice(line, 1, len(line))) 392 | ) 393 | readUntilPrefix := prefix => ( 394 | lines := (lineReader.readUntilPrefix)('>' + c) 395 | map(lines, line => slice(line, 1, len(line))) 396 | ) 397 | readUntilEnd := lineReader.readUntilEnd 398 | readUntilMatchingDelim := () => () `` NOTE: not implemented 399 | 400 | { 401 | peek: peek 402 | last: last 403 | back: back 404 | next: next 405 | expect?: expect? 406 | readUntil: readUntil 407 | readUntilPrefix: readUntilPrefix 408 | readUntilEnd: readUntilEnd 409 | readUntilMatchingDelim: readUntilMatchingDelim 410 | } 411 | ) 412 | 413 | { 414 | tag: Node.Quote 415 | children: parseDoc(BlockQuotedLineReader(lineReader, '>')) 416 | } 417 | ) 418 | 419 | parseCodeBlock := lineReader => ( 420 | peek := lineReader.peek 421 | next := lineReader.next 422 | 423 | startTag := next() ` swallow starting Pre tag ` 424 | lang := (rest := slice(startTag, 3, len(startTag)) :: { 425 | '' -> '' 426 | _ -> rest 427 | }) 428 | 429 | children := (sub := lines => lineNodeType(peek()) :: { 430 | Node.Pre -> lines 431 | () -> lines 432 | _ -> ( 433 | text := next() 434 | sub(lines.len(lines) := text) 435 | ) 436 | })([]) 437 | 438 | next() ` swallow ending pre tag ` 439 | 440 | { 441 | tag: Node.Pre 442 | children: [{ 443 | tag: Node.Code 444 | lang: lang 445 | children: unifyTextNodes(children, Newline) 446 | }] 447 | } 448 | ) 449 | 450 | parseRawHTML := lineReader => ( 451 | peek := lineReader.peek 452 | next := lineReader.next 453 | 454 | startMarkLine := next() 455 | firstLine := slice(startMarkLine, len('!html '), len(startMarkLine)) 456 | 457 | children := (sub := lines => lineNodeType(peek()) :: { 458 | Node.Empty -> lines 459 | () -> lines 460 | _ -> ( 461 | text := next() 462 | sub(lines.len(lines) := text) 463 | ) 464 | })([firstLine]) 465 | 466 | { 467 | tag: Node.RawHTML 468 | children: unifyTextNodes(children, Newline) 469 | } 470 | ) 471 | 472 | parseList := (lineReader, listType) => ( 473 | peek := lineReader.peek 474 | next := lineReader.next 475 | 476 | children := (sub := items => listItemLine?(peek()) :: { 477 | false -> items 478 | _ -> ( 479 | ` TODO: provide a way for one listItem to contain 2+ paragraphs. 480 | The current convention seems to be that if there is at least one 481 | multi-paragraph listItem in a UL, every listItem in the UL gets 482 |

s rather than inline text nodes as content. ` 483 | line := next() 484 | lineType := lineNodeType(line) 485 | reader := Reader(line) 486 | trimmer := (lineType :: { 487 | Node.UList -> trimUListGetLevel 488 | Node.OList -> trimOListGetLevel 489 | }) 490 | level := trimmer(reader) 491 | 492 | text := (reader.readUntilEnd)() 493 | listItem := { 494 | tag: Node.Item 495 | level: level 496 | children: parseText(tokenizeText(text)) 497 | } 498 | 499 | ` handle list items that have distinct levels ` 500 | lastItem := items.(len(items) - 1) :: { 501 | () -> sub(items.len(items) := listItem) 502 | _ -> lastItem.level :: { 503 | level -> lineType :: { 504 | ` if the same type of list, continue; otherwise, re-parse ` 505 | listType -> sub(items.len(items) := listItem) 506 | _ -> ( 507 | (lineReader.back)() 508 | items 509 | ) 510 | } 511 | _ -> lastItem.level < level :: { 512 | ` indent in: begin parsing a separate list ` 513 | true -> ( 514 | (lineReader.back)() 515 | list := parseList(lineReader, lineType) 516 | lastItem.children.len(lastItem.children) := list 517 | sub(items) 518 | ) 519 | ` indent out: give up control in this parsing depth ` 520 | _ -> ( 521 | (lineReader.back)() 522 | items 523 | ) 524 | } 525 | } 526 | } 527 | ) 528 | })([]) 529 | 530 | ` remove the level annotation ` 531 | children := map(children, child => child :: { 532 | { 533 | tag: Node.Item 534 | level: _ 535 | children: _ 536 | } -> {tag: Node.Item, children: child.children} 537 | _ -> child 538 | }) 539 | 540 | { 541 | tag: listType 542 | children: children 543 | } 544 | ) 545 | 546 | parseParagraph := lineReader => ( 547 | peek := lineReader.peek 548 | next := lineReader.next 549 | 550 | children := (sub := lines => lineNodeType(peek()) :: { 551 | Node.P -> ( 552 | text := next() 553 | [hasSuffix?(text, ' '), text.(len(text) - 1) = '\\'] :: { 554 | [true, _] -> ( 555 | append(lines, parseText(tokenizeText(slice(text, 0, len(text) - 2)))) 556 | sub(lines.len(lines) := {tag: Node.Br}) 557 | ) 558 | [_, true] -> ( 559 | append(lines, parseText(tokenizeText(slice(text, 0, len(text) - 1)))) 560 | sub(lines.len(lines) := {tag: Node.Br}) 561 | ) 562 | _ -> sub(append(lines, parseText(tokenizeText(text)))) 563 | } 564 | ) 565 | _ -> lines 566 | })([]) 567 | 568 | { 569 | tag: Node.P 570 | children: unifyTextNodes(children, ' ') 571 | } 572 | ) 573 | 574 | ` compile transforms a Markdown AST node ` 575 | compile := nodes => cat(map(nodes, compileNode), '') 576 | 577 | wrapTag := (tag, node) => f('<{{0}}>{{1}}', [ 578 | tag 579 | compile(node.children) 580 | ]) 581 | 582 | ` compileNode transforms an individual Markdown AST node into HTML ` 583 | compileNode := node => type(node) :: { 584 | 'string' -> replace(replace(node, '&', '&'), '<', '<') 585 | _ -> node.tag :: { 586 | Node.P -> wrapTag('p', node) 587 | Node.Em -> wrapTag('em', node) 588 | Node.Strong -> wrapTag('strong', node) 589 | Node.Strike -> wrapTag('strike', node) 590 | Node.A -> f('{{1}}', [node.href, compile(node.children)]) 591 | Node.H1 -> wrapTag('h1', node) 592 | Node.H2 -> wrapTag('h2', node) 593 | Node.H3 -> wrapTag('h3', node) 594 | Node.H4 -> wrapTag('h4', node) 595 | Node.H5 -> wrapTag('h5', node) 596 | Node.H6 -> wrapTag('h6', node) 597 | Node.Quote -> wrapTag('blockquote', node) 598 | Node.Img -> f('{{0}}', [ 599 | node.alt 600 | node.src 601 | ]) 602 | Node.Pre -> wrapTag('pre', node) 603 | Node.Code -> node.lang :: { 604 | '' -> wrapTag('code', node) 605 | () -> wrapTag('code', node) 606 | _ -> f('{{1}}', [node.lang, compile(node.children)]) 607 | } 608 | Node.UList -> wrapTag('ul', node) 609 | Node.OList -> wrapTag('ol', node) 610 | Node.Item -> wrapTag('li', node) 611 | Node.Checkbox -> f('', [node.checked :: { 612 | true -> 'checked' 613 | _ -> '' 614 | }]) 615 | Node.Br -> '
' 616 | Node.Hr -> '


' 617 | Node.RawHTML -> node.children.0 618 | _ -> f('Unknown Markdown node {{0}}', [string(node)]) 619 | } 620 | } 621 | 622 | ` transform wraps the Merlot Markdown parser and compiler into a single 623 | function to be invoked by the library consumer. ` 624 | transform := text => compile(parse(text)) 625 | 626 | -------------------------------------------------------------------------------- /vendor/quicksort.ink: -------------------------------------------------------------------------------- 1 | ` minimal quicksort implementation 2 | using hoare partition ` 3 | 4 | std := load('../vendor/std') 5 | 6 | map := std.map 7 | clone := std.clone 8 | 9 | sortBy := (v, pred) => ( 10 | vPred := map(v, pred) 11 | partition := (v, lo, hi) => ( 12 | pivot := vPred.(lo) 13 | lsub := i => (vPred.(i) < pivot) :: { 14 | true -> lsub(i + 1) 15 | false -> i 16 | } 17 | rsub := j => (vPred.(j) > pivot) :: { 18 | true -> rsub(j - 1) 19 | false -> j 20 | } 21 | (sub := (i, j) => ( 22 | i := lsub(i) 23 | j := rsub(j) 24 | (i < j) :: { 25 | false -> j 26 | true -> ( 27 | ` inlined swap! ` 28 | tmp := v.(i) 29 | tmpPred := vPred.(i) 30 | v.(i) := v.(j) 31 | v.(j) := tmp 32 | vPred.(i) := vPred.(j) 33 | vPred.(j) := tmpPred 34 | 35 | sub(i + 1, j - 1) 36 | ) 37 | } 38 | ))(lo, hi) 39 | ) 40 | (quicksort := (v, lo, hi) => len(v) :: { 41 | 0 -> v 42 | _ -> (lo < hi) :: { 43 | false -> v 44 | true -> ( 45 | p := partition(v, lo, hi) 46 | quicksort(v, lo, p) 47 | quicksort(v, p + 1, hi) 48 | ) 49 | } 50 | })(v, 0, len(v) - 1) 51 | ) 52 | 53 | sort! := v => sortBy(v, x => x) 54 | 55 | sort := v => sort!(clone(v)) 56 | -------------------------------------------------------------------------------- /vendor/reader.ink: -------------------------------------------------------------------------------- 1 | std := load('../vendor/std') 2 | str := load('../vendor/str') 3 | 4 | slice := std.slice 5 | append := std.append 6 | split := str.split 7 | hasPrefix? := str.hasPrefix? 8 | 9 | Newline := char(10) 10 | 11 | ` Type-generic Reader over an Ink iterable interface, i.e. strings and lists. 12 | This Reader is generic so that we can read through either a string (a list of 13 | chars) or a list of strings. ` 14 | Reader := s => ( 15 | S := {i: 0} 16 | 17 | peek := () => s.(S.i) 18 | last := () => s.(S.i - 1) 19 | back := () => S.i :: { 20 | 0 -> 0 21 | _ -> S.i := S.i - 1 22 | } 23 | next := () => S.i :: { 24 | len(s) -> () 25 | _ -> ( 26 | c := s.(S.i) 27 | S.i := S.i + 1 28 | c 29 | ) 30 | } 31 | expect? := prefix => hasPrefix?(slice(s, S.i, len(s)), prefix) :: { 32 | true -> ( 33 | S.i := S.i + len(prefix) 34 | true 35 | ) 36 | _ -> false 37 | } 38 | itemIndex := (list, it) => (sub := i => i < len(list) :: { 39 | true -> list.(i) :: { 40 | it -> i 41 | _ -> sub(i + 1) 42 | } 43 | _ -> ~1 44 | })(0) 45 | readUntil := c => i := itemIndex(slice(s, S.i, len(s)), c) :: { 46 | ~1 -> () 47 | _ -> ( 48 | substr := slice(s, S.i, S.i + i) 49 | S.i := S.i + i 50 | substr 51 | ) 52 | } 53 | readUntilPrefix := prefix => (sub := i => i + len(prefix) > len(s) :: { 54 | true -> () 55 | _ -> part := slice(s, i, i + len(prefix)) :: { 56 | prefix -> ( 57 | substr := slice(s, S.i, i) 58 | S.i := i 59 | substr 60 | ) 61 | _ -> sub(i + 1) 62 | } 63 | })(S.i) 64 | readUntilEnd := () => ( 65 | substr := slice(s, S.i, len(s)) 66 | S.i := len(s) 67 | substr 68 | ) 69 | ` readUntilMatchingDelim is a helper specifically for parsing delimited 70 | expressions like text in [] or (), that will attempt to read until a 71 | matching delimiter and return that read if the match exists, and return () 72 | if no match exists. This fn accounts for nested delimiters and ignores 73 | matching pairs within the delimited text expression. ` 74 | readUntilMatchingDelim := left => ( 75 | right := (left :: { 76 | ` currently only supprots [] and () (for Markdown links) ` 77 | '[' -> ']' 78 | '(' -> ')' 79 | _ -> () 80 | }) 81 | 82 | matchingDelimIdx := (sub := (i, stack) => stack :: { 83 | 0 -> i - 1 84 | _ -> c := s.(i) :: { 85 | () -> ~1 86 | left -> sub(i + 1, stack + 1) 87 | right -> sub(i + 1, stack - 1) 88 | _ -> sub(i + 1, stack) 89 | } 90 | })(S.i, 1) 91 | 92 | matchingDelimIdx :: { 93 | ~1 -> () 94 | _ -> ( 95 | substr := slice(s, S.i, matchingDelimIdx) 96 | S.i := matchingDelimIdx 97 | substr 98 | ) 99 | } 100 | ) 101 | 102 | { 103 | peek: peek 104 | last: last 105 | back: back 106 | next: next 107 | expect?: expect? 108 | readUntil: readUntil 109 | readUntilPrefix: readUntilPrefix 110 | readUntilEnd: readUntilEnd 111 | readUntilMatchingDelim: readUntilMatchingDelim 112 | } 113 | ) 114 | 115 | -------------------------------------------------------------------------------- /vendor/std.ink: -------------------------------------------------------------------------------- 1 | ` the ink standard library ` 2 | 3 | log := val => out(string(val) + ' 4 | ') 5 | 6 | scan := cb => ( 7 | acc := [''] 8 | in(evt => evt.type :: { 9 | 'end' -> cb(acc.0) 10 | 'data' -> ( 11 | acc.0 := acc.0 + slice(evt.data, 0, len(evt.data) - 1) 12 | false 13 | ) 14 | }) 15 | ) 16 | 17 | ` hexadecimal conversion utility functions ` 18 | hToN := {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, 'a': 10, 'b': 11, 'c': 12, 'd': 13, 'e': 14, 'f': 15} 19 | nToH := '0123456789abcdef' 20 | 21 | ` take number, return hex string ` 22 | hex := n => (sub := (p, acc) => p < 16 :: { 23 | true -> nToH.(p) + acc 24 | false -> sub(floor(p / 16), nToH.(p % 16) + acc) 25 | })(floor(n), '') 26 | 27 | ` take hex string, return number ` 28 | xeh := s => ( 29 | ` i is the num of places from the left, 0-indexed ` 30 | max := len(s) 31 | (sub := (i, acc) => i :: { 32 | max -> acc 33 | _ -> sub(i + 1, acc * 16 + hToN.(s.(i))) 34 | })(0, 0) 35 | ) 36 | 37 | ` find minimum in list ` 38 | min := numbers => reduce(numbers, (acc, n) => n < acc :: { 39 | true -> n 40 | false -> acc 41 | }, numbers.0) 42 | 43 | ` find maximum in list ` 44 | max := numbers => reduce(numbers, (acc, n) => n > acc :: { 45 | true -> n 46 | false -> acc 47 | }, numbers.0) 48 | 49 | ` like Python's range(), but no optional arguments ` 50 | range := (start, end, step) => ( 51 | span := end - start 52 | sub := (i, v, acc) => (v - start) / span < 1 :: { 53 | true -> ( 54 | acc.(i) := v 55 | sub(i + 1, v + step, acc) 56 | ) 57 | false -> acc 58 | } 59 | 60 | ` preempt potential infinite loops ` 61 | (end - start) / step > 0 :: { 62 | true -> sub(0, start, []) 63 | false -> [] 64 | } 65 | ) 66 | 67 | ` clamp start and end numbers to ranges, such that 68 | start < end. Utility used in slice ` 69 | clamp := (start, end, min, max) => ( 70 | start := (start < min :: { 71 | true -> min 72 | false -> start 73 | }) 74 | end := (end < min :: { 75 | true -> min 76 | false -> end 77 | }) 78 | end := (end > max :: { 79 | true -> max 80 | false -> end 81 | }) 82 | start := (start > end :: { 83 | true -> end 84 | false -> start 85 | }) 86 | 87 | { 88 | start: start 89 | end: end 90 | } 91 | ) 92 | 93 | ` get a substring of a given string, or sublist of a given list ` 94 | slice := (s, start, end) => ( 95 | ` bounds checks ` 96 | x := clamp(start, end, 0, len(s)) 97 | start := x.start 98 | max := x.end - start 99 | 100 | (sub := (i, acc) => i :: { 101 | max -> acc 102 | _ -> sub(i + 1, acc.(i) := s.(start + i)) 103 | })(0, type(s) :: { 104 | 'string' -> '' 105 | 'composite' -> [] 106 | }) 107 | ) 108 | 109 | ` join one list to the end of another, return the original first list ` 110 | append := (base, child) => ( 111 | baseLength := len(base) 112 | childLength := len(child) 113 | (sub := i => i :: { 114 | childLength -> base 115 | _ -> ( 116 | base.(baseLength + i) := child.(i) 117 | sub(i + 1) 118 | ) 119 | })(0) 120 | ) 121 | 122 | ` join one list to the end of another, return the third list ` 123 | join := (base, child) => append(clone(base), child) 124 | 125 | ` clone a composite value ` 126 | clone := x => type(x) :: { 127 | 'string' -> '' + x 128 | 'composite' -> reduce(keys(x), (acc, k) => acc.(k) := x.(k), {}) 129 | _ -> x 130 | } 131 | 132 | ` tail recursive numeric list -> string converter ` 133 | stringList := list => '[' + cat(map(list, string), ', ') + ']' 134 | 135 | ` tail recursive reversing a list ` 136 | reverse := list => (sub := (acc, i) => i < 0 :: { 137 | true -> acc 138 | _ -> sub(acc.len(acc) := list.(i), i - 1) 139 | })([], len(list) - 1) 140 | 141 | ` tail recursive map ` 142 | map := (list, f) => reduce(list, (l, item, i) => l.(i) := f(item, i), {}) 143 | 144 | ` tail recursive filter ` 145 | filter := (list, f) => reduce(list, (l, item, i) => f(item, i) :: { 146 | true -> l.len(l) := item 147 | _ -> l 148 | }, []) 149 | 150 | ` tail recursive reduce ` 151 | reduce := (list, f, acc) => ( 152 | max := len(list) 153 | (sub := (i, acc) => i :: { 154 | max -> acc 155 | _ -> sub(i + 1, f(acc, list.(i), i)) 156 | })(0, acc) 157 | ) 158 | 159 | ` tail recursive reduce from list end ` 160 | reduceBack := (list, f, acc) => (sub := (i, acc) => i :: { 161 | ~1 -> acc 162 | _ -> sub(i - 1, f(acc, list.(i), i)) 163 | })(len(list) - 1, acc) 164 | 165 | ` flatten by depth 1 ` 166 | flatten := list => reduce(list, append, []) 167 | 168 | ` true iff some items in list are true ` 169 | some := list => reduce(list, (acc, x) => acc | x, false) 170 | 171 | ` true iff every item in list is true ` 172 | every := list => reduce(list, (acc, x) => acc & x, true) 173 | 174 | ` concatenate (join) a list of strings into a string ` 175 | cat := (list, joiner) => max := len(list) :: { 176 | 0 -> '' 177 | _ -> (sub := (i, acc) => i :: { 178 | max -> acc 179 | _ -> sub(i + 1, acc.len(acc) := joiner + list.(i)) 180 | })(1, clone(list.0)) 181 | } 182 | 183 | ` for-each loop over a list ` 184 | each := (list, f) => ( 185 | max := len(list) 186 | (sub := i => i :: { 187 | max -> () 188 | _ -> ( 189 | f(list.(i), i) 190 | sub(i + 1) 191 | ) 192 | })(0) 193 | ) 194 | 195 | ` encode string buffer into a number list ` 196 | encode := str => ( 197 | max := len(str) 198 | (sub := (i, acc) => i :: { 199 | max -> acc 200 | _ -> sub(i + 1, acc.(i) := point(str.(i))) 201 | })(0, []) 202 | ) 203 | 204 | ` decode number list into an ascii string ` 205 | decode := data => reduce(data, (acc, cp) => acc.len(acc) := char(cp), '') 206 | 207 | ` utility for reading an entire file ` 208 | readFile := (path, cb) => ( 209 | BufSize := 4096 ` bytes ` 210 | (sub := (offset, acc) => read(path, offset, BufSize, evt => evt.type :: { 211 | 'error' -> cb(()) 212 | 'data' -> ( 213 | dataLen := len(evt.data) 214 | dataLen = BufSize :: { 215 | true -> sub(offset + dataLen, acc.len(acc) := evt.data) 216 | false -> cb(acc.len(acc) := evt.data) 217 | } 218 | ) 219 | }))(0, '') 220 | ) 221 | 222 | ` utility for writing an entire file 223 | it's not buffered, because it's simpler, but may cause jank later 224 | we'll address that if/when it becomes a performance issue ` 225 | writeFile := (path, data, cb) => delete(path, evt => evt.type :: { 226 | ` write() by itself will not truncate files that are too long, 227 | so we delete the file and re-write. Not efficient, but writeFile 228 | is not meant for large files ` 229 | 'end' -> write(path, 0, data, evt => evt.type :: { 230 | 'error' -> cb(()) 231 | 'end' -> cb(true) 232 | }) 233 | _ -> cb(()) 234 | }) 235 | 236 | ` template formatting with {{ key }} constructs ` 237 | format := (raw, values) => ( 238 | ` parser state ` 239 | state := { 240 | ` current position in raw ` 241 | idx: 0 242 | ` parser internal state: 243 | 0 -> normal 244 | 1 -> seen one { 245 | 2 -> seen two { 246 | 3 -> seen a valid } ` 247 | which: 0 248 | ` buffer for currently reading key ` 249 | key: '' 250 | ` result build-up buffer ` 251 | buf: '' 252 | } 253 | 254 | ` helper function for appending to state.buf ` 255 | append := c => state.buf := state.buf + c 256 | 257 | ` read next token, update state ` 258 | readNext := () => ( 259 | c := raw.(state.idx) 260 | 261 | state.which :: { 262 | 0 -> c :: { 263 | '{' -> state.which := 1 264 | _ -> append(c) 265 | } 266 | 1 -> c :: { 267 | '{' -> state.which := 2 268 | ` if it turns out that earlier brace was not 269 | a part of a format expansion, just backtrack ` 270 | _ -> ( 271 | append('{' + c) 272 | state.which := 0 273 | ) 274 | } 275 | 2 -> c :: { 276 | '}' -> ( 277 | ` insert key value ` 278 | state.buf := state.buf + string(values.(state.key)) 279 | state.key := '' 280 | state.which := 3 281 | ) 282 | ` ignore spaces in keys -- not allowed ` 283 | ' ' -> () 284 | _ -> state.key := state.key + c 285 | } 286 | 3 -> c :: { 287 | '}' -> state.which := 0 288 | ` ignore invalid inputs -- treat them as nonexistent ` 289 | _ -> () 290 | } 291 | } 292 | 293 | state.idx := state.idx + 1 294 | ) 295 | 296 | ` main recursive sub-loop ` 297 | max := len(raw) 298 | (sub := () => state.idx < max :: { 299 | true -> ( 300 | readNext() 301 | sub() 302 | ) 303 | false -> state.buf 304 | })() 305 | ) 306 | -------------------------------------------------------------------------------- /vendor/str.ink: -------------------------------------------------------------------------------- 1 | ` standard string library ` 2 | 3 | std := load('std') 4 | 5 | map := std.map 6 | slice := std.slice 7 | reduce := std.reduce 8 | reduceBack := std.reduceBack 9 | 10 | ` checking if a given character is of a type ` 11 | checkRange := (lo, hi) => c => ( 12 | p := point(c) 13 | lo < p & p < hi 14 | ) 15 | upper? := checkRange(point('A') - 1, point('Z') + 1) 16 | lower? := checkRange(point('a') - 1, point('z') + 1) 17 | digit? := checkRange(point('0') - 1, point('9') + 1) 18 | letter? := c => upper?(c) | lower?(c) 19 | 20 | ` is the char a whitespace? ` 21 | ws? := c => point(c) :: { 22 | ` space ` 23 | 32 -> true 24 | ` newline ` 25 | 10 -> true 26 | ` hard tab ` 27 | 9 -> true 28 | ` carriage return ` 29 | 13 -> true 30 | _ -> false 31 | } 32 | 33 | ` hasPrefix? checks if a string begins with the given prefix substring ` 34 | hasPrefix? := (s, prefix) => reduce(prefix, (acc, c, i) => acc & (s.(i) = c), true) 35 | 36 | ` hasSuffix? checks if a string ends with the given suffix substring ` 37 | hasSuffix? := (s, suffix) => ( 38 | diff := len(s) - len(suffix) 39 | reduce(suffix, (acc, c, i) => acc & (s.(i + diff) = c), true) 40 | ) 41 | 42 | ` mostly used for internal bookkeeping, matchesAt? reports if a string contains 43 | the given substring at the given index idx. ` 44 | matchesAt? := (s, substring, idx) => ( 45 | max := len(substring) 46 | (sub := i => i :: { 47 | max -> true 48 | _ -> s.(idx + i) :: { 49 | (substring.(i)) -> sub(i + 1) 50 | _ -> false 51 | } 52 | })(0) 53 | ) 54 | 55 | ` index is indexOf() for ink strings ` 56 | index := (s, substring) => ( 57 | max := len(s) - 1 58 | (sub := i => matchesAt?(s, substring, i) :: { 59 | true -> i 60 | false -> i < max :: { 61 | true -> sub(i + 1) 62 | false -> ~1 63 | } 64 | })(0) 65 | ) 66 | 67 | ` contains? checks if a string contains the given substring ` 68 | contains? := (s, substring) => index(s, substring) > ~1 69 | 70 | ` transforms given string to lowercase ` 71 | lower := s => reduce(s, (acc, c, i) => upper?(c) :: { 72 | true -> acc.(i) := char(point(c) + 32) 73 | false -> acc.(i) := c 74 | }, '') 75 | 76 | ` transforms given string to uppercase` 77 | upper := s => reduce(s, (acc, c, i) => lower?(c) :: { 78 | true -> acc.(i) := char(point(c) - 32) 79 | false -> acc.(i) := c 80 | }, '') 81 | 82 | ` primitive "title-case" transformation, uppercases first letter 83 | and lowercases the rest. ` 84 | title := s => ( 85 | lowered := lower(s) 86 | lowered.0 := upper(lowered.0) 87 | ) 88 | 89 | replaceNonEmpty := (s, old, new) => ( 90 | lold := len(old) 91 | lnew := len(new) 92 | (sub := (acc, i) => matchesAt?(acc, old, i) :: { 93 | true -> sub( 94 | slice(acc, 0, i) + new + slice(acc, i + lold, len(acc)) 95 | i + lnew 96 | ) 97 | false -> i < len(acc) :: { 98 | true -> sub(acc, i + 1) 99 | false -> acc 100 | } 101 | })(s, 0) 102 | ) 103 | 104 | ` replace all occurrences of old substring with new substring in a string ` 105 | replace := (s, old, new) => old :: { 106 | '' -> s 107 | _ -> replaceNonEmpty(s, old, new) 108 | } 109 | 110 | splitNonEmpty := (s, delim) => ( 111 | coll := [] 112 | ldelim := len(delim) 113 | (sub := (acc, i, last) => matchesAt?(acc, delim, i) :: { 114 | true -> ( 115 | coll.len(coll) := slice(acc, last, i) 116 | sub(acc, i + ldelim, i + ldelim) 117 | ) 118 | false -> i < len(acc) :: { 119 | true -> sub(acc, i + 1, last) 120 | false -> coll.len(coll) := slice(acc, last, len(acc)) 121 | } 122 | })(s, 0, 0) 123 | ) 124 | 125 | ` split given string into a list of substrings, splitting by the delimiter ` 126 | split := (s, delim) => delim :: { 127 | '' -> map(s, c => c) 128 | _ -> splitNonEmpty(s, delim) 129 | } 130 | 131 | trimPrefixNonEmpty := (s, prefix) => ( 132 | max := len(s) 133 | lpref := len(prefix) 134 | idx := (sub := i => i < max :: { 135 | true -> matchesAt?(s, prefix, i) :: { 136 | true -> sub(i + lpref) 137 | false -> i 138 | } 139 | false -> i 140 | })(0) 141 | slice(s, idx, len(s)) 142 | ) 143 | 144 | ` trim string from start until it does not begin with prefix. 145 | trimPrefix is more efficient than repeated application of 146 | hasPrefix? because it minimizes copying. ` 147 | trimPrefix := (s, prefix) => prefix :: { 148 | '' -> s 149 | _ -> trimPrefixNonEmpty(s, prefix) 150 | } 151 | 152 | trimSuffixNonEmpty := (s, suffix) => ( 153 | lsuf := len(suffix) 154 | idx := (sub := i => i > ~1 :: { 155 | true -> matchesAt?(s, suffix, i - lsuf) :: { 156 | true -> sub(i - lsuf) 157 | false -> i 158 | } 159 | false -> i 160 | })(len(s)) 161 | slice(s, 0, idx) 162 | ) 163 | 164 | ` trim string from end until it does not end with suffix. 165 | trimSuffix is more efficient than repeated application of 166 | hasSuffix? because it minimizes copying. ` 167 | trimSuffix := (s, suffix) => suffix :: { 168 | '' -> s 169 | _ -> trimSuffixNonEmpty(s, suffix) 170 | } 171 | 172 | ` trim string from both start and end with substring ss ` 173 | trim := (s, ss) => trimPrefix(trimSuffix(s, ss), ss) 174 | -------------------------------------------------------------------------------- /vendor/suite.ink: -------------------------------------------------------------------------------- 1 | ` ink standard test suite tools ` 2 | 3 | std := load('std') 4 | 5 | ` borrow from std ` 6 | log := std.log 7 | each := std.each 8 | f := std.format 9 | 10 | ` suite constructor ` 11 | suite := label => ( 12 | ` suite data store ` 13 | s := { 14 | all: 0 15 | passed: 0 16 | msgs: [] 17 | } 18 | 19 | ` mark sections of a test suite with human labels ` 20 | mark := label => s.msgs.len(s.msgs) := '- ' + label 21 | 22 | ` signal end of test suite, print out results ` 23 | end := () => ( 24 | log(f('suite: {{ label }}', {label: label})) 25 | each(s.msgs, m => log(' ' + m)) 26 | s.passed :: { 27 | s.all -> log(f('ALL {{ passed }} / {{ all }} PASSED', s)) 28 | _ -> ( 29 | log(f('PARTIAL: {{ passed }} / {{ all }} PASSED', s)) 30 | exit(1) 31 | ) 32 | } 33 | ) 34 | 35 | ` log a passed test ` 36 | onSuccess := () => ( 37 | s.all := s.all + 1 38 | s.passed := s.passed + 1 39 | ) 40 | 41 | ` log a failed test ` 42 | onFail := msg => ( 43 | s.all := s.all + 1 44 | s.msgs.len(s.msgs) := msg 45 | ) 46 | 47 | ` perform a new test case ` 48 | indent := ' ' + ' ' + ' ' + ' ' 49 | test := (label, result, expected) => result :: { 50 | expected -> onSuccess() 51 | _ -> ( 52 | msg := f(' * {{ label }} 53 | {{ indent }}got {{ result }} 54 | {{ indent }}exp {{ expected }}', { 55 | label: label 56 | result: result 57 | expected: expected 58 | indent: indent 59 | }) 60 | onFail(msg) 61 | ) 62 | } 63 | 64 | ` expose API functions ` 65 | { 66 | mark: mark 67 | test: test 68 | end: end 69 | } 70 | ) 71 | --------------------------------------------------------------------------------