mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2af8c68339 | |||
| b1e40c480b | |||
| 8c1f7647a1 | |||
| e7bb5126cb | |||
| f3ea6ab2a9 | |||
| ed842e4bea | |||
| 8d67108984 | |||
| 58e6da6a2c | |||
| 3ad59c0761 | |||
| 6a4debe35a | |||
| b3c2d4745d | |||
| 8404227c22 | |||
| 55a0d25173 | |||
| 604a80ca42 | |||
| 806c153979 | |||
| 4c41d85385 | |||
| c92d5e5611 | |||
| afb98c3bc4 | |||
| 7eb116c731 | |||
| 616880b3da | |||
| e4f5c63346 | |||
| dd77289f5b | |||
| dd76a8e83c | |||
| 10be1b92c4 | |||
| 49d08c3129 | |||
| 6eebaa0a6c | |||
| e1b2c9d321 | |||
| 86c5428f77 | |||
| 4ed581373b | |||
| 5e2bee4897 | |||
| 54d3bed630 | |||
| 073695b7c7 | |||
| 4d2fdf284d | |||
| 25551a4fb6 | |||
| b06cf6f8b1 | |||
| a420803b3e | |||
| 040c462bba | |||
| fa8179c9b9 | |||
| 8416411c62 | |||
| f54da58698 | |||
| f370da6e55 | |||
| ceae8bfa8b | |||
| e64cd929e6 | |||
| de328b3bcb | |||
| 8cb7d1243b | |||
| 126548cb29 | |||
| c289c3c7f9 | |||
| d1d0af60e7 | |||
| 6d7b88a8d6 | |||
| f1c7d02bc1 | |||
| 6ec8fcf2a1 | |||
| b81aa38c62 | |||
| 9a35fa2051 | |||
| 178b02da74 |
@@ -2,6 +2,18 @@
|
||||
|
||||
Thor is a static site generator written in [Odin](https://odin-lang.org), replacing Hugo for the `sbrow.github.io` blog. It lives at `./thor/` as a git subtree with its own `flake.nix`.
|
||||
|
||||
## 🚫 DO NOT EDIT THE DOCS — BY HUMANS, FOR HUMANS
|
||||
|
||||
> **THE DOCUMENTATION UNDER `thor/site/` IS HANDWRITTEN, BY HUMANS, FOR HUMANS.**
|
||||
>
|
||||
> **NO AI, AGENT, BOT, ASSISTANT, OR OTHER NON-HUMAN MAY EDIT, REWRITE,
|
||||
> REPHRASE, REFORMAT, "IMPROVE," SUMMARIZE, OR GENERATE ANY FILE UNDER
|
||||
> `thor/site/` — EVER.**
|
||||
>
|
||||
> These are not machine artifacts. A human wrote every word. AI may be
|
||||
> consulted as a sanity check, but the prose stays human. If you are not a
|
||||
> human, do not touch these files. See `thor/site/content/ai.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -399,12 +411,11 @@ Spec-compliant implementation at `mustache/`. See `mustache/SPEC.md` for the imp
|
||||
|---|---|
|
||||
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), renderer (`render_nodes` with `Indent_State` for partial indentation), template inheritance (`merge_block_overrides`), `delete_template`/`delete_partials`. Pipe support in Variable/Unescaped/Section/Inverted tags. |
|
||||
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), standalone whitespace detection |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `extract_list_element`, `call_interp_lambda`/`call_section_lambda` |
|
||||
| `data.odin` | Reflection-based data model: `base_value` (peels union/any/nested-any layers), `lookup_in` (structs + maps, handles `Type_Info_Any` value kind in maps), `resolve_name`, `is_truthy`, `any_to_string`, `write_value`, `list_info`, `extract_list_element`, `collect_map_keys` |
|
||||
| `pipes.odin` | Pipes extension: `Pipe_Filter` AST, `parse_pipeline` (takes `pos`), `apply_pipeline`, `apply_filter` (switch dispatch: `group_by` + `format`), `apply_group_by`, `apply_format`. Stored on `Node.filters`; render-scoped results in temp allocator. |
|
||||
| `diagnostic.odin` | Rust-style error formatter: `format_error` (multi-line context, ANSI colors via `core:terminal/ansi`, `colorize` param), `format_render_error` (formats `Error`), `line_col`, `line_text`, `context_extent`, `count_lines`, `digit_count`, `should_colorize`. |
|
||||
| `suggest.odin` | Strict-warning helpers: `validate_key_path` (walks dotted path, crosses maps silently), `suggest_correction` (Levenshtein via `core:strings/levenshtein_distance`), `collect_struct_keys` (via reflection, recurses into `using`), `struct_has_field` (distinguishes missing field from nil value — needed for `Maybe(bool)`), `collect_partial_names`, `collect_block_names`. |
|
||||
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case. Uses `log.nil_logger()` to suppress expected warnings. |
|
||||
| `lambda_test.odin` | Spec lambda tests |
|
||||
| `pipes_test.odin` | Pipe filter tests (`group_by` + `format`) |
|
||||
| `diagnostic_test.odin` | Golden-output tests for `format_error` (multi-line context, edge cases, alignment, caret position, hint) + parser error message brace-escaping |
|
||||
| `suggest_test.odin` | Tests for `validate_key_path`, `suggest_correction`, `struct_has_field` with `Maybe(bool)` and `using`-promoted fields |
|
||||
@@ -448,13 +459,6 @@ Rust-style error messages with multi-line source context, caret underlines, and
|
||||
|
||||
**Block override source tracking**: `Block_Override.source: Template` ensures warnings inside block overrides point at the override's source file (e.g., `page.html`), not the parent template (`base.html`).
|
||||
|
||||
### Lambdas
|
||||
|
||||
Spec-compliant. Stored as `any` values in the data context.
|
||||
|
||||
- **Interpolation lambdas**: `proc() -> string`, `proc() -> int`, `proc() -> bool` — called via `call_interp_lambda`, result stringified and escaped.
|
||||
- **Section lambdas**: `proc(string) -> string`, `proc(string) -> int`, `proc(string) -> bool` — called via `call_section_lambda` with the raw section text (`node.content`). String result is re-parsed as mustache and rendered against the current context stack.
|
||||
|
||||
### Pipes
|
||||
|
||||
`{{key | op args…}}` for interpolation, `{{#key | op args…}}…{{/key}}` for sections. Stored as `[dynamic; MAX_PIPES]Pipe_Filter` on each `Node`. Applied in the renderer via `apply_pipeline` before truthiness/interpolation. Implemented filters:
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
name: Missing 2nd closing brace
|
||||
input: |
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
|
||||
expected: Somthing that points to the opening and lack of close.
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] unexpected {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.social}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing 2nd opening brace
|
||||
input: |
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
expected: Somthing that shows the whole block, and points out the missing opening brace
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Invalid timezone
|
||||
input: |
|
||||
{
|
||||
"date": {
|
||||
"timezone": "America/New_Yorkside"
|
||||
}
|
||||
}
|
||||
expected: "Did you mean 'America/New_York'? pointing to line+col no in the config"
|
||||
actual: |
|
||||
[WARN ] --- [148:init_site()] unable to load timezone 'America/New_Yorkside'
|
||||
---
|
||||
name: Mismatched section tags
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.ocial}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: Mostly the same, but with rust style markers at the opening and close
|
||||
actual: |
|
||||
[ERROR] --- [383:load_partials()] expected {{/params.ocial}}, got {{/params.social}}
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:6:5
|
||||
|
|
||||
4 | {{#params.ocial}}
|
||||
5 | <a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
6 | {{/params.social}}
|
||||
| ^^^^^^^^^^^^^^^^^^
|
||||
7 | </menu>
|
||||
8 | <p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
---
|
||||
name: Missing closing section
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
<!-- {{/params.social}} -->
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing closing tag '{{/params.social}}' ...opened <here> ... expected close <here>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: empty tag
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Found an empty tag at <line:col>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key ''
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Missing pipe arguement
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | group_by }} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Missing filter argument <pointing to exact location>"
|
||||
actual: |
|
||||
[ERROR] --- [152:render_template()] group_by expects 1 argument, got 0
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Double dot access
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }} {{params..name}}</p>
|
||||
</footer>
|
||||
expected: Error "Invalid access key"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Too many opening braces
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{{{now | format "2006" }}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many opening braces <point to location>"
|
||||
actual: |
|
||||
[WARN ] --- [920:warn_unknown_key()] unknown key '{now'
|
||||
--> /home/spencer/github.com/sbrow.github.io/layouts/partials/footer.html:1:1
|
||||
|
|
||||
1 | <footer>
|
||||
| ^ did you mean 'now'?
|
||||
2 | <menu>
|
||||
3 | <a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
|
|
||||
---
|
||||
name: Too many closing braces
|
||||
input: |
|
||||
<footer>
|
||||
<menu>
|
||||
<a class="goto-top opacity-0" href="#">{{> icons/chevron_up}}</a>
|
||||
{{#params.social}}
|
||||
<a href="{{url}}" target="_blank" rel="noopener noreferrer me" title="{{name}}">{{>* icon}}</a>
|
||||
{{/params.social}}
|
||||
</menu>
|
||||
<p>Proudly built with <a href="https://github.com/sbrow/thor/">Thor</a> and <a
|
||||
href="https://edwardtufte.github.io/tufte-css/">Tufte CSS</a>
|
||||
</p>
|
||||
<p><small>©</small> {{now | format "2006" }}}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
expected: "Too many closing braces <Point to location>"
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: Frontmatter missing closing quote on key
|
||||
input: |
|
||||
{
|
||||
"title: Docs"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
expected: 'TODO:'
|
||||
actual: |
|
||||
[ERROR] --- [42:parse_frontmatter()] failed to parse frontmatter JSON: Expected_Colon_After_Key
|
||||
---
|
||||
name: Fronmatter missing opening quote on value
|
||||
input: |
|
||||
{
|
||||
"title": Docs"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
expected: 'detailed diagnostic pointing to the point of failure and suggesting the addition of a quote'
|
||||
actual: Nothing. The page compiles with no warnings or errors
|
||||
---
|
||||
name: No error when missing index page.
|
||||
expected: 'Your site has no index page! Please create an index.md or index.html file in ./content/'
|
||||
actual: Nothing. The site compiles with no warnings or errors
|
||||
---
|
||||
name: format pipe with no date format set
|
||||
expected: |
|
||||
Show where the error was triggered. Should also show the format that will be used
|
||||
actual: |
|
||||
[ERROR] --- [333:apply_format()] format pipe used but no date format configured (set date.format in thor.json) Default will be used
|
||||
@@ -1,8 +1,21 @@
|
||||
## High priority
|
||||
|
||||
- Polish existing features before moving on to new ones.
|
||||
- [x] `{{>title}}` default partial? `{{site.title}} | {{ page.title }}`
|
||||
- [ ] implicit titles (Set when missing?)
|
||||
- [ ] create a default `head.html`.
|
||||
- [ ] html comments are rendered, except in minify mode.
|
||||
- [ ] does it make sense for partials to be inside layouts?
|
||||
- current: `layouts/partials`
|
||||
- alt1: `layouts`, `partials`
|
||||
- alt2: `templates/layouts`, `templates/partials`
|
||||
- [ ] [aliases](https://gohugo.io/methods/page/aliases/#redirects)?
|
||||
- [ ] Improve diagnostics
|
||||
- [x] keep track of every error and don't report them more than once.
|
||||
- [ ] `*` make sure the frontmatter parser has good diagnostics.
|
||||
- [ ] fix the diagnostics in [DIAGNOSTIC TODOS](./DIAGNOSTIC_TODOS.yaml)
|
||||
- [ ] only report format errors once.
|
||||
- [ ] only report missing partiall errors once.
|
||||
- [ ] All Diagnostics should show:
|
||||
- [ ] *What* went wrong
|
||||
- [ ] *where* (in the file)
|
||||
@@ -14,9 +27,6 @@
|
||||
- [ ] better diagnostics for syntax errors in treesitter.
|
||||
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
|
||||
- [ ] improve matching weights message.
|
||||
- [ ] show a proper diagnostic for timezones
|
||||
- currently "unable to load timezone 'America/New_Yorkskie'"
|
||||
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
|
||||
- [ ] Test menu diagnostics
|
||||
- [ ] Honestly, Test **all** diagnostics
|
||||
- [ ] Need to be careful about diagnostics across module boundaries.
|
||||
@@ -30,17 +40,17 @@
|
||||
- [ ] centralize diagnostics to one place.
|
||||
- [ ] consider logging the number of times an error occurred.
|
||||
- [ ] Load grammars dynamically
|
||||
- [ ] starred must be a param.
|
||||
- [x] starred must be a param.
|
||||
- [ ] Documentation
|
||||
- [ ] talk about the context stack (and its limit).
|
||||
- [ ] highlight the differences in the way menus are handled.
|
||||
- [ ] consider sites with date based urls.
|
||||
- [ ] Don't show annoying log output in tests.
|
||||
- [x] Don't show annoying log output in tests.
|
||||
- [ ] improve home link customization.
|
||||
- [ ] currently an accessibility issue.
|
||||
- [ ] support JSON5 in in frontmatter
|
||||
- [x] support JSON5 in in frontmatter
|
||||
- [ ] Create a json schema file for `thor.json`.
|
||||
- [ ] cleanup `#partial switch`es.
|
||||
- [x] cleanup `#partial switch`es.
|
||||
- [ ] improve json diagnostics.
|
||||
- i.e. "Missing quotes around string", etc.
|
||||
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
|
||||
@@ -97,7 +107,10 @@
|
||||
- [ ] Add overloads for every extension - accept ^strings.Builder.
|
||||
- [ ] Add conventional (Hugo style) footnotes option.
|
||||
- [ ] Add opt-in deflist support.
|
||||
- [ ] Decide if lambdas actually provide any value.
|
||||
- [x] Decide if lambdas actually provide any value.
|
||||
- [ ] add tables extension
|
||||
- [ ] Peruse [GitHub's](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts)
|
||||
docs for any juicy nuggets we may have missed.
|
||||
|
||||
## Dates
|
||||
- [ ] display an error when no part of the date appears in the output.
|
||||
@@ -109,6 +122,9 @@
|
||||
- [ ] configure opt-out of automatic sections being added to menu.
|
||||
- [ ] nested menus (i.e. `parent` support)
|
||||
- [ ] get rid of the global variables in the `treesitter` package.
|
||||
- [ ] enforce heading structure.
|
||||
- [ ] Either frontmatter.title set, or 1 h1 tag at top, not both
|
||||
- [ ] No skipping.
|
||||
- [ ] Consider using `or_else` when applying default values to structs. i.e.
|
||||
```odin
|
||||
package main
|
||||
@@ -140,7 +156,7 @@ main :: proc () {
|
||||
- [ ] etc
|
||||
- [ ] Avoid `json.Value` / `json.Object` where possible.
|
||||
- [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something.
|
||||
- [ ] Add page params
|
||||
- [x] Add page params
|
||||
- [ ] We must remove all mention of `posts` from the odin code.
|
||||
At present, "posts" are a user-level construct defined as pages in a
|
||||
particular collection.
|
||||
@@ -157,7 +173,6 @@ main :: proc () {
|
||||
- sets `-minify`
|
||||
- [ ] Mustache diagnostics
|
||||
- [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site.
|
||||
- [ ] Could be better error message when missing a closing (or opening) brace
|
||||
- [x] Error message doesn't show position of faulty pipe name correctly.
|
||||
- [ ] `render_template` (`render.odin`) blanks the *entire page* to `""` on any
|
||||
mustache render error and only `log.errorf`s it — a single bad tag/pipe
|
||||
@@ -196,7 +211,8 @@ main :: proc () {
|
||||
- [x] basic poll loop
|
||||
- [ ] filesystem poll loop
|
||||
- [ ] event based
|
||||
- [ ] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [x] Free cmark HTML output (`body_html`) — cmark allocates via C malloc, not the arena, so it leaks per iteration in watch mode
|
||||
- [ ] manually pass `site_allocator` to `load_page`
|
||||
- [ ] Mount content in VFS
|
||||
- [ ] commands
|
||||
- [ ] `build` alias of default
|
||||
|
||||
+4
-3
@@ -3,9 +3,9 @@ package main
|
||||
import md "markdown"
|
||||
import ts "treesitter"
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:encoding/json"
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
@@ -275,9 +275,9 @@ load_page :: proc(
|
||||
page.og = fm.og
|
||||
|
||||
if strings.has_suffix(file_path, ".html") {
|
||||
page.content = strings.clone(body)
|
||||
page.content = strings.clone(body, context.allocator)
|
||||
} else {
|
||||
page.content = md.process(body, ext, file_path)
|
||||
page.content = md.process(body, ext, file_path, context.allocator)
|
||||
}
|
||||
|
||||
ok = true
|
||||
@@ -295,3 +295,4 @@ strip_extension :: proc(name: string) -> string {
|
||||
}
|
||||
return name[:dot]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{title}}</title>
|
||||
{{> head}}
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<title>{{> title}}</title>
|
||||
{{> opengraph}}
|
||||
{{> styles }}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{{&content}}
|
||||
</header>
|
||||
<ul>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
{{#pages}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/pages}}
|
||||
</ul>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<main>
|
||||
<article>
|
||||
<h1>{{page.title}}</h1>
|
||||
{{#date_iso}} <time class="subtitle" datetime="{{date_iso}}">{{date_display}}</time>
|
||||
{{/date_iso}} {{&content}}
|
||||
{{#date}} <time class="subtitle" datetime="{{date}}">{{ date | format}}</time>
|
||||
{{/date}} {{&content}}
|
||||
</article>
|
||||
</main>
|
||||
{{/main}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<footer>
|
||||
<p>© {{now.year}} {{params.author.name}}</p>
|
||||
<p>© {{now | format "2006"}} {{params.author.name}}</p>
|
||||
</footer>
|
||||
@@ -1 +1 @@
|
||||
{{site.title}}
|
||||
{{site.title}}{{^site.title}}Home{{/site.title}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{{#params.stylesheets}}<link rel="stylesheet" href="{{.}}">{{/params.stylesheets}}
|
||||
@@ -0,0 +1 @@
|
||||
{{#site.title}}{{.}}{{#page.title}} | {{/page.title}}{{/site.title}}{{page.title}}
|
||||
@@ -7,8 +7,8 @@
|
||||
<section>
|
||||
<h2>{{key}}</h2>
|
||||
<ul>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date_iso}}<time
|
||||
datetime="{{date_iso}}">{{date_display}}</time>{{/date_iso}}</span>
|
||||
{{#items}} <li><a href="{{permalink}}">{{&title}}</a><span>{{#date}}<time
|
||||
datetime="{{date}}">{{date | format}}</time>{{/date}}</span>
|
||||
</li>
|
||||
{{/items}}
|
||||
</ul>
|
||||
|
||||
+2
-1
@@ -37,7 +37,7 @@ parse_frontmatter :: proc(content: string) -> (fm: Frontmatter, body: string, ok
|
||||
json_str := content[:end + 1]
|
||||
body = strings.trim_left(content[end + 1:], " \t\r\n")
|
||||
|
||||
value, err := json.parse_string(json_str, spec = .JSON)
|
||||
value, err := json.parse_string(json_str, spec = .JSON5)
|
||||
if err != nil {
|
||||
log.errorf("failed to parse frontmatter JSON: %v", err)
|
||||
return
|
||||
@@ -117,3 +117,4 @@ json_get_open_graph :: proc(obj: json.Object, key: string) -> Open_Graph {
|
||||
}
|
||||
return og
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:flags"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
import "core:os"
|
||||
import "core:prof/spall"
|
||||
import "core:sync"
|
||||
@@ -43,9 +45,21 @@ main :: proc() {
|
||||
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
|
||||
cli_flags: Flags
|
||||
flags.parse_or_exit(&cli_flags, os.args, .Odin)
|
||||
|
||||
level: log.Level
|
||||
switch {
|
||||
case cli_flags.quiet:
|
||||
level = .Warning
|
||||
case cli_flags.verbose:
|
||||
level = .Debug
|
||||
case:
|
||||
level = .Info
|
||||
}
|
||||
logger_opts: log.Options =
|
||||
(log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path})
|
||||
console_logger := log.create_console_logger(.Info, logger_opts)
|
||||
console_logger := log.create_console_logger(level, logger_opts)
|
||||
context.logger = console_logger
|
||||
defer log.destroy_console_logger(console_logger)
|
||||
|
||||
@@ -57,12 +71,21 @@ main :: proc() {
|
||||
|
||||
for {
|
||||
defer free_all(context.temp_allocator)
|
||||
defer log.debugf(
|
||||
"max_temp_allocator_size=%M",
|
||||
(cast(^runtime.Default_Temp_Allocator)context.temp_allocator.data)^.arena.total_used,
|
||||
)
|
||||
tick := time.tick_now()
|
||||
site: Site
|
||||
init_site(&site, os.args)
|
||||
init_site(&site, cli_flags)
|
||||
defer destroy_site(&site)
|
||||
// TODO: Make it so this isn't necessary
|
||||
context.allocator = site_allocator(&site)
|
||||
defer log.debugf(
|
||||
"current_site_allocator_size=%M",
|
||||
site.arena.block_size * (len(site.arena.used_blocks) + len(site.arena.unused_blocks)) -
|
||||
site.arena.bytes_left,
|
||||
)
|
||||
build_vfs(&site)
|
||||
|
||||
treesitter.grammar_dir = site.grammars
|
||||
@@ -70,7 +93,7 @@ main :: proc() {
|
||||
|
||||
site_load_content(&site)
|
||||
render_site(&site)
|
||||
log.infof("Built site in %s", time.tick_since(tick))
|
||||
log.infof("Built site in %M", time.tick_since(tick))
|
||||
|
||||
(.Watch in site.features) or_break
|
||||
time.sleep(5 * time.Second)
|
||||
@@ -94,3 +117,4 @@ when SPALL {
|
||||
spall._buffer_end(&spall_ctx, &spall_buffer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,8 +171,9 @@ inject_notes :: proc(html: string, sn_defs, mn_defs: map[string]string) -> strin
|
||||
}
|
||||
|
||||
// Render definition through cmark for markdown support
|
||||
def_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
def_html = strip_p_tags(def_html)
|
||||
raw_html := cm.markdown_to_html_from_string(def_text, {.Unsafe})
|
||||
defer cm.free_string(raw_html)
|
||||
def_html := strip_p_tags(raw_html)
|
||||
|
||||
note: string
|
||||
defer delete(note)
|
||||
@@ -210,3 +211,4 @@ strip_p_tags :: proc(html: string) -> string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -16,14 +16,23 @@ Extension :: enum {
|
||||
|
||||
DEFAULT_EXTENSIONS :: bit_set[Extension]{.Emoji, .Sidenotes, .Alerts, .HeadingIDs}
|
||||
|
||||
process :: proc(body: string, ext: bit_set[Extension], file_path: string) -> string {
|
||||
// Caller is responsible for freeing string
|
||||
process :: proc(
|
||||
body: string,
|
||||
ext: bit_set[Extension],
|
||||
file_path: string,
|
||||
allocator := context.allocator,
|
||||
) -> string {
|
||||
side_notes := make(map[string]string)
|
||||
margin_notes := make(map[string]string)
|
||||
clean_body := body
|
||||
if .Sidenotes in ext {
|
||||
clean_body, side_notes, margin_notes = strip_definitions(body)
|
||||
}
|
||||
html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
original_html := cm.markdown_to_html_from_string(clean_body, {.Unsafe})
|
||||
html := strings.clone(original_html, allocator)
|
||||
cm.free_string(original_html)
|
||||
|
||||
if .Emoji in ext {
|
||||
html = expand_emoji(html)
|
||||
}
|
||||
@@ -88,3 +97,4 @@ apply_extension_config :: proc(ext: ^bit_set[Extension], config: json.Object) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,32 +175,6 @@ is_truthy :: proc(a: any) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc() -> string:
|
||||
return v(), true
|
||||
case proc() -> int:
|
||||
return fmt.tprintf("%d", v()), true
|
||||
case proc() -> bool:
|
||||
return "true" if v() else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
|
||||
switch v in val {
|
||||
case proc(_: string) -> string:
|
||||
return v(text), true
|
||||
case proc(_: string) -> int:
|
||||
return fmt.tprintf("%d", v(text)), true
|
||||
case proc(_: string) -> bool:
|
||||
return "true" if v(text) else "false", true
|
||||
case:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// list_info returns element type info, count, and data pointer for a list value.
|
||||
// Returns elem_info=nil if the value is not a list.
|
||||
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:testing"
|
||||
|
||||
/*
|
||||
|
||||
// --- Spec test 1: Interpolation ---
|
||||
// A lambda's return value should be interpolated.
|
||||
|
||||
Interp_Data :: struct {
|
||||
lambda: proc() -> string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
Interp_Data_Int :: struct {
|
||||
lambda: proc() -> int,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "world"},
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 2: Interpolation - Expansion ---
|
||||
// A lambda's return value should be parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_expansion :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return "{{planet}}"},
|
||||
planet = "world",
|
||||
}
|
||||
tpl, _ := parse("Hello, {{lambda}}!", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Hello, world!")
|
||||
}
|
||||
|
||||
// --- Spec test 4: Interpolation - Multiple Calls ---
|
||||
// Interpolated lambdas should not be cached.
|
||||
|
||||
counter_lambda :: proc() -> int {
|
||||
@(static) call_count := 0
|
||||
call_count += 1
|
||||
return call_count
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_interpolation_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Interp_Data_Int {
|
||||
lambda = counter_lambda,
|
||||
}
|
||||
tpl, _ := parse("{{lambda}} == {{{lambda}}} == {{lambda}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "1 == 2 == 3")
|
||||
}
|
||||
|
||||
// --- Spec test 5: Escaping ---
|
||||
// Lambda results should be appropriately escaped.
|
||||
|
||||
@(test)
|
||||
test_lambda_escaping :: proc(t: ^testing.T) {
|
||||
data := Interp_Data {
|
||||
lambda = proc() -> string {return ">"},
|
||||
}
|
||||
tpl, _ := parse("<{{lambda}}{{{lambda}}}", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>>")
|
||||
}
|
||||
|
||||
// --- Spec test 6: Section ---
|
||||
// Lambdas used for sections should receive the raw section string.
|
||||
|
||||
Section_Data :: struct {
|
||||
lambda: proc(_: string) -> string,
|
||||
x: string,
|
||||
planet: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_section :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
if text == "{{x}}" {return "yes"} else {return "no"}
|
||||
},
|
||||
x = "Error!",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}{{x}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<yes>")
|
||||
}
|
||||
|
||||
// --- Spec test 7: Section - Expansion ---
|
||||
// Lambdas used for sections should have their results parsed.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_expansion :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("%s{{{{planet}}}}%s", text, text)
|
||||
},
|
||||
planet = "Earth",
|
||||
}
|
||||
tpl, _ := parse("<{{#lambda}}-{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<-Earth->")
|
||||
}
|
||||
|
||||
// --- Spec test 9: Section - Multiple Calls ---
|
||||
// Lambdas used for sections should not be cached.
|
||||
|
||||
@(test)
|
||||
test_lambda_section_multiple_calls :: proc(t: ^testing.T) {
|
||||
data := Section_Data {
|
||||
lambda = proc(text: string) -> string {
|
||||
return fmt.tprintf("__%s__", text)
|
||||
},
|
||||
}
|
||||
tpl, _ := parse(
|
||||
"{{#lambda}}FILE{{/lambda}} != {{#lambda}}LINE{{/lambda}}",
|
||||
"<test>",
|
||||
context.temp_allocator,
|
||||
)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "__FILE__ != __LINE__")
|
||||
}
|
||||
|
||||
// --- Spec test 10: Inverted Section ---
|
||||
// Lambdas used for inverted sections should be considered truthy.
|
||||
|
||||
Inverted_Data :: struct {
|
||||
lambda: proc(_: string) -> bool,
|
||||
static: string,
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_lambda_inverted_section :: proc(t: ^testing.T) {
|
||||
data := Inverted_Data {
|
||||
lambda = proc(text: string) -> bool {return false},
|
||||
static = "static",
|
||||
}
|
||||
tpl, _ := parse("<{{^lambda}}{{static}}{{/lambda}}>", "<test>", context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "<>")
|
||||
}
|
||||
|
||||
*/
|
||||
+1
-65
@@ -625,30 +625,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = true)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = true)
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Unescaped:
|
||||
@@ -667,30 +644,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_interp_lambda(val); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
temp: strings.Builder
|
||||
strings.builder_init(&temp, context.temp_allocator)
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
&temp,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
write_value(b, strings.to_string(temp), escape = false)
|
||||
}
|
||||
} else {
|
||||
write_value(b, val, escape = false)
|
||||
}
|
||||
i += 1
|
||||
|
||||
case .Section:
|
||||
@@ -705,25 +659,7 @@ render_nodes :: proc(
|
||||
}
|
||||
val = transformed
|
||||
}
|
||||
if result_str, ok := call_section_lambda(val, node.content); ok {
|
||||
sub_tpl, perr := parse(
|
||||
result_str,
|
||||
fmt.tprintf("<lambda output from '%s'>", node.key),
|
||||
context.temp_allocator,
|
||||
context.temp_allocator,
|
||||
)
|
||||
if perr == nil {
|
||||
render_nodes(
|
||||
sub_tpl,
|
||||
sub_tpl.nodes[:],
|
||||
ctx,
|
||||
partials,
|
||||
b,
|
||||
blocks,
|
||||
nil,
|
||||
) or_return
|
||||
}
|
||||
} else if is_truthy(val) {
|
||||
if is_truthy(val) {
|
||||
children := node.children
|
||||
elem_info, count, data := list_info(val)
|
||||
if elem_info != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import "core:time/datetime"
|
||||
|
||||
Template_Context :: struct {
|
||||
now: string,
|
||||
title: string,
|
||||
date_format: string,
|
||||
timezone: ^datetime.TZ_Region,
|
||||
og: Open_Graph,
|
||||
@@ -288,7 +287,6 @@ render_page_html :: proc(
|
||||
seen: ^map[string]bool,
|
||||
) -> string {
|
||||
ctx := ctx
|
||||
ctx.title = fmt.tprintf("%s | %s", page.title, site.title)
|
||||
ctx.page = page
|
||||
ctx.og = og_for_page(site.og, page)
|
||||
ctx.params = merge_params(site.params, page.params)
|
||||
@@ -312,7 +310,6 @@ render_home_html :: proc(
|
||||
}
|
||||
|
||||
ctx := ctx
|
||||
ctx.title = site.title
|
||||
ctx.pages = list_pages
|
||||
ctx.og = og_for_page(site.og, home)
|
||||
ctx.params = merge_params(site.params, home.params)
|
||||
@@ -342,14 +339,12 @@ render_section :: proc(
|
||||
ctx := ctx
|
||||
if has_index {
|
||||
ctx.page = section_index
|
||||
ctx.title = fmt.tprintf("%s | %s", section_index.title, site.title)
|
||||
ctx.og = og_for_page(site.og, section_index)
|
||||
} else {
|
||||
title := to_title_case(section, alloc)
|
||||
ctx.page = Page {
|
||||
title = title,
|
||||
}
|
||||
ctx.title = fmt.tprintf("%s | %s", ctx.page.title, site.title)
|
||||
ctx.og.title = title
|
||||
ctx.og.description = ""
|
||||
ctx.og.url = fmt.tprintf("%s/%s/", site.base_url, section)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:mem"
|
||||
@@ -88,11 +87,13 @@ Flags :: struct {
|
||||
drafts: bool `args:"name=drafts" usage:"Include draft pages in the build"`,
|
||||
watch: bool `usage:"Rebuild on file changes (polls every 5 seconds)"`,
|
||||
minify: bool `args:"name=minify" usage:"Minify HTML output and CSS assets"`,
|
||||
verbose: bool `usage:"Enable debug logging"`,
|
||||
quiet: bool `usage:"Suppress info logging (warnings and errors only)"`,
|
||||
md_enable: string `args:"name=ext" usage:"Enable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
|
||||
md_disable: string `args:"name=no-ext" usage:"Disable markdown extensions (comma-separated: emoji,sidenotes,alerts,highlight,sections)"`,
|
||||
}
|
||||
|
||||
init_site :: proc(site: ^Site, args: []string) {
|
||||
init_site :: proc(site: ^Site, flags: Flags) {
|
||||
mem.dynamic_arena_init(&site.arena)
|
||||
alloc := site_allocator(site)
|
||||
|
||||
@@ -100,10 +101,7 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
site.base_url = "http://localhost:8080"
|
||||
site.markdown_extensions = md.DEFAULT_EXTENSIONS
|
||||
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, args, .Odin, alloc)
|
||||
|
||||
path := _flags.config_path
|
||||
path := flags.config_path
|
||||
if path == "" {
|
||||
found, ok := find_config("thor.json")
|
||||
if ok {
|
||||
@@ -128,7 +126,7 @@ init_site :: proc(site: ^Site, args: []string) {
|
||||
site_apply_path_defaults(site, config_dir)
|
||||
}
|
||||
|
||||
site_apply_cli_flags(site, _flags)
|
||||
site_apply_cli_flags(site, flags)
|
||||
site.config_path = path
|
||||
|
||||
site.og = og_for_site(site)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public
|
||||
@@ -0,0 +1,24 @@
|
||||
# Docs Site AGENTS.md
|
||||
|
||||
## DO NOT EDIT — BY HUMANS, FOR HUMANS
|
||||
|
||||
All content under `thor/site/` is handwritten by humans. No AI, agent,
|
||||
bot, or assistant may edit, rewrite, or generate any file here. AI may
|
||||
be consulted as a sanity check, but the prose stays human.
|
||||
|
||||
## Implicit limits
|
||||
|
||||
The mustache engine imposes limits that template authors should be aware of.
|
||||
These values are defined in `thor/mustache/pipes.odin`, and `thor/mustache/mustache.odin`
|
||||
and must be kept in sync with the source code if they ever change.
|
||||
|
||||
- **Nested partials** — deeply nested partial chains (partial-in-partial)
|
||||
consume context stack frames during rendering. The limit is defined by
|
||||
`MAX_CONTEXT_STACK` (16) in the mustache engine. In practice, 3–4 levels of
|
||||
nesting is typical and safe.
|
||||
|
||||
- **`MAX_PIPES` (8)** — a single tag may chain up to 8 pipe filters:
|
||||
`{{key | op1 | op2 | ... | op8}}`. Exceeding this is a parse error.
|
||||
|
||||
- **`MAX_PIPE_ARGS` (2)** — each pipe filter accepts at most 2 arguments:
|
||||
`{{key | op arg1 arg2}}`.
|
||||
@@ -0,0 +1,38 @@
|
||||
[TOC]
|
||||
|
||||
Thor is a simple Static Sire Generator designed for personal blogs and other small websites.
|
||||
|
||||
Its core principals are simplicity and minimal configuration, so you can get started as quickly as possible.
|
||||
|
||||
It is based on Hugo, and gingerbill's SSG. Templating is done with (extended?) Mustache templates.
|
||||
|
||||
## What it does
|
||||
|
||||
- Syntax Highlighting server-side (with Tree Sitter) or client-side with highlight.js
|
||||
- Mustache Templating
|
||||
- OpenGraph Tags
|
||||
- Menus (WIP)
|
||||
- Extended Markdown ([See below](#extended-markdown))
|
||||
- Basic (whitespace) minification.
|
||||
|
||||
## What it doesn't do
|
||||
- Internationalization
|
||||
- Pagination (Yet)
|
||||
- Themes
|
||||
- Union File System (Yet)
|
||||
- Image Manipulation
|
||||
- TailwindCSS integration
|
||||
|
||||
## Getting Started
|
||||
|
||||
Run `thor`
|
||||
Check out `public/index.html`
|
||||
|
||||
Then follow [The Guide]()
|
||||
For a more complete setup, run `thor new site`.
|
||||
|
||||
## Extended Markdown
|
||||
- Emoji expansion
|
||||
- margin style footnotes
|
||||
- Guthub style alerts
|
||||
- [and more]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "AI",
|
||||
"date": "2026-07-28T12:16:00-04:00"
|
||||
}
|
||||
## Prose
|
||||
|
||||
This site, and all the documentation on it was written for humans, by humans.
|
||||
AI gets consulted as a sanity check, i.e. "Did I miss anything here?", "Does the documentation match the code's behaviour?", etc. All prose is hand-written by a human being.
|
||||
|
||||
## Code
|
||||
|
||||
The code is a completely different story. The majority of code is written with GLM-4.2 via OpenCode under programmer direction. I focus on architectural decisions, and I read the code emitted by the AI with varying degrees of scrutiny based on whether it touches a major part of the architecture, is in a hotpath, or holds some other high level of significance.
|
||||
@@ -0,0 +1,329 @@
|
||||
{
|
||||
"title": "Docs",
|
||||
"date": "2026-07-22T08:54:00-04:00"
|
||||
}
|
||||
|
||||
[TOC]
|
||||
|
||||
## Introduction
|
||||
|
||||
This guide assumes you have either read [The Guide](../guide), or have built a [Hugo](https://gohugo.io) site before. It also assumes you have a basic knowledge of HTML and CSS.
|
||||
|
||||
## Features
|
||||
|
||||
Thor has many features and content processors available. In an effort to provide the best out of the box experience, most of them are enabled by default.
|
||||
|
||||
### Opt-In Features
|
||||
|
||||
TODO: #### deflist syntax
|
||||
|
||||
TODO: #### footnotes
|
||||
|
||||
#### Minify
|
||||
|
||||
If enabled, `minify` will perform simple whitespace removal on all your output `.html` files, and any `.css` files in your `assets` directories. Minifying JavaScript is not supported (yet).
|
||||
|
||||
TODO: Do we minify inline css?
|
||||
|
||||
### Opt-Out Features
|
||||
|
||||
- emoji
|
||||
- sidenotes/marginnotes
|
||||
- syntax highlighting
|
||||
- heading ids
|
||||
- TODO: Table Of Contents Generation
|
||||
|
||||
|
||||
## Directories
|
||||
Like Hugo, a Thor project is a collection of specially named directories, plus a config file.
|
||||
|
||||
content
|
||||
|
||||
: `content` holds your pages and page bundles.
|
||||
|
||||
layouts
|
||||
|
||||
: `layouts` holds your templates and partials.
|
||||
|
||||
assets
|
||||
: `assets` contains any static files for your site (favicon.ico, etc.), as well as files you want to send through the asset pipeline (CSS or JS files).
|
||||
|
||||
public
|
||||
: `public` will contain your completed site.
|
||||
|
||||
All of these names can be remapped in `thor.json`.
|
||||
|
||||
> [!NOTE] While directories can be remapped at the site level, modules must (currently) adhere to the defaults.
|
||||
|
||||
### Asset pipeline
|
||||
|
||||
Currently, there is only one asset processor, and that is [the minifier](#minify).
|
||||
|
||||
### Pages & Page Bundles
|
||||
|
||||
Page content can either be defined in a single file (`contact.md`), or in a directory (`contact/index.md` + `contact/our-team.jpg`). Single file pages are preferred to page bundles.[^1]
|
||||
|
||||
[^1]: That's not you say you shouldn't use bundles, but if you have no additional resources on your page, there's no benefit to using a bundle.
|
||||
|
||||
Thor currently supports 2 formats for page files: MarkDown (`.md`), and HTML (`.html`).
|
||||
|
||||
## Templates[^tempmod]
|
||||
|
||||
[^tempmod]: Template modification is an "advanced" feature, and shoud probably be discussed later in the page. (or possibly in the guide.)
|
||||
|
||||
Sites are built using one or more template files written in an extended version of [mustache](https://mustache.github.io) templates. The [mustache manual](https://mustache.github.io/mustache.5.html) has great explainations and a lot of examples if you want to know more, but I'll summarize them for you here.
|
||||
|
||||
### Tags
|
||||
|
||||
#### Variables
|
||||
|
||||
In order do display a scalar (not-list) value in your template, simply wrap it in double curly braces. e.g. `{{ page.title }}`.
|
||||
|
||||
This content will be HTML escaped (for safety), so if the value you're rendering contains html, you'll need to use the raw syntex instead `{{& page.title}}` which will output the value without stripping or re-writing content.
|
||||
|
||||
`{{{ raw }}}` syntax is supported for raw html output, but `{{& raw }}` is preferred, as it's easy to accidentily insert too many braces.
|
||||
|
||||
In most[^most] cases, invalid keys will be silently ignored (nothing between the braces will appear), in keeping with the official mustache spec.
|
||||
|
||||
[^most]: TODO: in what cases won't it? spelllcheck + strict mode?
|
||||
|
||||
Leading and trailing whitespace(s) are ignored by the parser, so the folllowing are all equivilent: `{{& title }}`, `{{&title}}`, `{{& title}}`.
|
||||
|
||||
#### Sections
|
||||
TODO:
|
||||
|
||||
#### Inverted Sections
|
||||
TODO:
|
||||
|
||||
#### Partials
|
||||
TODO:
|
||||
|
||||
TODO: Talk about MAX_CONTEXT_DEPTH (currently 16) and how it limits the total number of nested templates to 13. (3 for `[site, page, ctx]`)
|
||||
|
||||
##### Dynamic Partials
|
||||
|
||||
#### Blocks
|
||||
TODO:
|
||||
|
||||
#### Parents
|
||||
TODO:
|
||||
|
||||
#### Summary
|
||||
|
||||
`{{page.title}}` for normal values
|
||||
`{{&page.title}}` for values that contain HTML.
|
||||
`<ul>{{#pages}}<li>{{title}}</li>{{/pages}}</ul>` for list values.
|
||||
`{{#params.is_starred}}<i class="fas fa-star"></i>{{/params.is_starred}}` for conditional content.
|
||||
`{{^pages}}No pages yet!{{/pages}}` to draw content when the value is empty.
|
||||
|
||||
### Section Names
|
||||
|
||||
When building your own templates, you are of course free to pick whatever names you choose for your partials and content slots. However, sticking to conventions helps create consistency in the ecosystem, and reduces friction when relying on a built-in template.
|
||||
|
||||
`{{$main}}...{{/main}}`
|
||||
|
||||
: the `main` section should be used in templates to denote the part of the page that is unique to that page. For the most part, your templates should look like this
|
||||
|
||||
```mustache
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
<h1>Actual page content goes here</h1>
|
||||
{{&page.content}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
|
||||
```
|
||||
|
||||
### Context
|
||||
|
||||
When building your page(s), the following keys are accessible to your template files:
|
||||
|
||||
`now`
|
||||
|
||||
: The Current `DateTime`. see [DateTime](#datetimes)
|
||||
|
||||
`title`
|
||||
|
||||
: The title of the current page. Unless overridden, it will be expand to
|
||||
`{{ page.title }} | {{ site.title }}`. [^title]
|
||||
|
||||
[^title]: Is there actually a way to overwrite this?
|
||||
|
||||
`date_format`
|
||||
|
||||
: The default format to use for dates. Configured in `thor.json:date.format`.
|
||||
|
||||
`timezone`
|
||||
|
||||
: The timezone to convert dates to when using `{{ date | format }}`. Configured in `thor.json:date.timezone`.
|
||||
|
||||
`site`
|
||||
|
||||
: The site parameters that are usable in templates, see [site](#site).
|
||||
|
||||
`pages`
|
||||
|
||||
: Returns all regular pages, sorted by `?`. Regular pages exclude index pages like home and section roots.
|
||||
|
||||
`og`
|
||||
|
||||
: Contains the [Open Graph](https://ogp.me/) metadata for the current page.
|
||||
|
||||
#### Page
|
||||
|
||||
`page.content`
|
||||
|
||||
: The HTML rendered page content
|
||||
|
||||
TODO: write
|
||||
|
||||
#### Site
|
||||
|
||||
TODO: Write
|
||||
|
||||
#### Params
|
||||
|
||||
`site.params` and `page.params` are an escape hatch to let users inject arbetrary data into their website. The following are some conventional examples theme developers may want to use to ensure a consistent experience across themes.
|
||||
|
||||
`author`
|
||||
|
||||
: The author of the current page or site. See [schema.org](https://schema.org/author) for the recommended format.
|
||||
|
||||
|
||||
#### The Context Stack
|
||||
When building your page(s), each template is fed a Context[^ctx] stack that contains all the data should you need to build your page.
|
||||
|
||||
[^ctx]: (for developers) `Template_Context` is not the same as Odin's implicit `context` parameter.
|
||||
|
||||
The context stack is initialized[^init] as `[site, page, context]`, meaning if you use a key like `{{title}}` in your template, it will look up `context.title`, `page.title`, and then finally `site.title`, using the first available value it can find.
|
||||
|
||||
[^init]: TODO: Don't use programmer speak.
|
||||
|
||||
TODO: As you descend into sections/partials, new contexts are placed onto the stack, so they resolve first. (LIFO order)
|
||||
|
||||
```mustache
|
||||
{{<base}}
|
||||
{{$main}}
|
||||
<main>
|
||||
{{&page.content}}
|
||||
<!-- Is the same as -->
|
||||
{{&content}}
|
||||
<!-- Or -->
|
||||
{{$page}}{{&content}}{{/page}}
|
||||
<!-- Or -->
|
||||
{{$page.content}}{{&.}}{{/page.content}}
|
||||
</main>
|
||||
{{/main}}
|
||||
{{/base}}
|
||||
```
|
||||
|
||||
|
||||
### Filters
|
||||
|
||||
Because mustache is a logic-less language, (there are no `for` or `if` tags), Thor extends mustache to include a handful of data filters in the form of pipes. Bash users will feel right at home here.
|
||||
|
||||
`group_by <field>`
|
||||
|
||||
: Allows you to group pages by the given field. e.g.
|
||||
```mustache
|
||||
{{#pages | group_by year }}
|
||||
<section>
|
||||
<header>{{$key}} {{! The group's year}}</header>
|
||||
<ul>
|
||||
{{#items}}<li>
|
||||
{{title}} {{! The title of each of that year's pages }}
|
||||
{{/items}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
{{/pages}}
|
||||
```
|
||||
|
||||
`sort_by <field> <asc|desc>`
|
||||
|
||||
: Allows you to sort pages by the given field.
|
||||
|
||||
`first <n>`
|
||||
|
||||
: Given a list, returns only the first `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`last <n>`
|
||||
|
||||
: Given a list, returns only the last `n` items. `n` defaults to 1. Given a string, returns the first `n` runes of the string. To help catch mistakes, `n` is required for strings.
|
||||
|
||||
`format "<format string>"`
|
||||
|
||||
: Used to display a date in a particular format. See [DateTimes](#datetimes). If no format is given, the default will be used.
|
||||
|
||||
#### Chaining Pipes
|
||||
|
||||
Thor allows you to chain two or more pipes, to allow complex data manipulation. However for performance and stylistic reasons, you are limited to no more than **8 pipes**[^pipes] for any given tag. If you believe you need more than 8 pipes, please [open an issue](../issues) with a **concrete example** of the problem you are facing.
|
||||
|
||||
[^pipes]: TODO: THis number must be kept in-sync with `MAX_PIPES`.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```mustache
|
||||
{{ pages | group_by year | first }} {{! All pages from the current year }}
|
||||
```
|
||||
|
||||
```mustache
|
||||
{{ pages | sort_by date desc | first }} {{! The latest page }}
|
||||
```
|
||||
|
||||
|
||||
### Partials
|
||||
|
||||
Partials are templates that render a portion of a page. To include a partial, the standard [mustache syntax](https://mustache.github.io/mustache.5.html#Partials) is used. All partials are resolved relative to the root partials directory, so to include a partial at `layouts/partials/my_partial.html`, you would use `{{> my_partial}}`.
|
||||
|
||||
Users can create or override as many partials as they want; several are included for convienience:
|
||||
|
||||
`{{> opengraph}}`
|
||||
|
||||
: This partial willl render the Open Graph meta tags for your page. It should be placed inside the `<head>` tag. See the [Open Graph](#open-graph) section for more details.
|
||||
|
||||
### DateTimes
|
||||
|
||||
Dates are strings in one of the following formats:
|
||||
|
||||
| Format | Time zone |
|
||||
| --------------------------- | ---------------------------- |
|
||||
| `2023-10-15T13:18:50-07:00` | `America/Los_Angeles` |
|
||||
| `2023-10-15T13:18:50-0700` | `America/Los_Angeles` |
|
||||
| `2023-10-15T13:18:50Z` | `Etc/UTC` |
|
||||
| `2023-10-15T13:18:50` | Default is local system time |
|
||||
| `2023-10-15` | Default is local system time |
|
||||
| `15 Oct 2023` | Default is local system time |
|
||||
|
||||
If you want to display a date in a different format, you can use the `| format` Filter. With no argument, it will default to formatting the date with `site.date_format`.
|
||||
|
||||
## Menus
|
||||
|
||||
TODO: Describe
|
||||
|
||||
## Open Graph
|
||||
|
||||
TODO: default Template support
|
||||
TODO: Template override support
|
||||
TODO: how to overwrite defaults (in page frontmatter / thor.json)
|
||||
|
||||
## Zen
|
||||
|
||||
> The guding principals behind Thor.
|
||||
|
||||
Simpler is Better
|
||||
|
||||
: We are [Grug](https://grugbrain.dev/) developers. We do everything the "dumb" way first, then optimize the **measured** bottlenecks.
|
||||
|
||||
Zero is Beautiful
|
||||
|
||||
: The less configuration needed, the better. A site can be built from multiple modules or a single `index.md`.
|
||||
|
||||
On By Default
|
||||
|
||||
: Users shouldn't have to enable features, unless those features significantly impact performance or mangle regular content.
|
||||
|
||||
Fault Tolerant
|
||||
|
||||
: When possible, mistakes should be recovered with warnings. When fatal errors occur, the user should have all the information they need to fix it quickly.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"title": "Guide",
|
||||
"date": "2026-07-12T08:55:00-04:00"
|
||||
}
|
||||
[TOC]
|
||||
|
||||
Thank you for choosing thor for your site building needs. This guide attempts to make it as easy as possible to get started.
|
||||
|
||||
> [!NOTE]: This guide assumes you have a basic knowledge of using command line interfaces, file systems, and text editors.
|
||||
|
||||
|
||||
Start by creating a new directory called `my-site`. This will be the home of your first Thor site.
|
||||
|
||||
If you haven't already, running `thor` in your new directory should create this guide in `./public/index.html`.
|
||||
|
||||
At the heart of any good SSG is the content files. Thor supports markdown (`.md`) and `.html` files.
|
||||
|
||||
Let's make a home page - copy the following text and place it into `./index.md`
|
||||
|
||||
```md
|
||||
# Hello, World!
|
||||
|
||||
Welcome to your new site.
|
||||
```
|
||||
|
||||
Run `thor` again, and you should see new content in `public/index.html`.
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Home"
|
||||
}
|
||||
TODO: Add content here
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"params": {
|
||||
"author": {
|
||||
"name": "Spencer Brower"
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-13
@@ -3,6 +3,7 @@
|
||||
package main
|
||||
|
||||
import "core:encoding/json"
|
||||
import "core:flags"
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
@@ -108,8 +109,9 @@ test_init_site_defaults_no_config :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./nonexistent.json"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "./content")
|
||||
@@ -127,8 +129,9 @@ test_init_site_config_dir_relative :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./sub/nonexistent.json"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./sub/nonexistent.json"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "./sub/content")
|
||||
@@ -142,8 +145,9 @@ test_init_site_flag_overrides_default :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", "-config:./nonexistent.json", "-drafts", "-base-url:https://flag.com"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect(t, .Drafts in site.features)
|
||||
@@ -161,8 +165,9 @@ test_init_site_full_pipeline :: proc(t: ^testing.T) {
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path), "-drafts"}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.title, "Pipeline Test")
|
||||
@@ -176,13 +181,14 @@ test_init_site_md_enable_disable :: proc(t: ^testing.T) {
|
||||
context.logger = log.nil_logger()
|
||||
|
||||
site: Site
|
||||
args := []string {
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string {
|
||||
"thor",
|
||||
"-config:./nonexistent.json",
|
||||
"-ext:highlight,sections",
|
||||
"-no-ext:emoji",
|
||||
}
|
||||
init_site(&site, args)
|
||||
}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect(t, .Highlight in site.markdown_extensions)
|
||||
@@ -207,8 +213,9 @@ test_init_site_config_paths :: proc(t: ^testing.T) {
|
||||
defer os.remove(path)
|
||||
|
||||
site: Site
|
||||
args := []string{"thor", fmt.tprintf("-config:%s", path)}
|
||||
init_site(&site, args)
|
||||
_flags: Flags
|
||||
flags.parse_or_exit(&_flags, []string{"thor", fmt.tprintf("-config:%s", path)}, .Odin)
|
||||
init_site(&site, _flags)
|
||||
defer destroy_site(&site)
|
||||
|
||||
testing.expect_value(t, site.content_dir, "/custom/content")
|
||||
|
||||
Reference in New Issue
Block a user