From 317264b45bd5731c7c92f648b6b7ea5a81348380 Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Sun, 30 Aug 2026 13:31:41 -0400
Subject: [PATCH] feat: Added `rel_url` pipe for subsites.
---
ROADMAP.md | 1 +
TODOS.md | 3 +
defaults/layouts/home.html | 2 +-
defaults/layouts/partials/nav.html | 4 +-
defaults/layouts/partials/styles.html | 2 +-
defaults/layouts/section_index.html | 2 +-
mustache/TODOS.md | 1 +
mustache/pipes.odin | 97 +++++++++++++++++++++------
site.odin | 49 ++++++++++++++
site/thor.json | 3 +-
10 files changed, 139 insertions(+), 25 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index c165e2a..c6d070a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -87,6 +87,7 @@
- [ ] don't use bullshit "sub-tokens", add filters and pipes as proper tokens.
- [ ] Create a Location struct that somewhat matches Odin's [Source_Code_Location](https://pkg.odin-lang.org/base/runtime/#Source_Code_Location)?
- Note that odin's version doesn't contain the stack trace.
+- [ ] Link checker / linter that warns if links don't resolve. ("please use {{whatever | rel_url }}").
### Memory Management
diff --git a/TODOS.md b/TODOS.md
index fe2ccd5..bb8be87 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -43,6 +43,9 @@ Remember to polish existing features before moving on to new ones.
- do we want to do this or just use DIAGNOSTIC_TESTS.md as the tests?
- [ ] also update to use file logger so they don't clog up the output
- [ ] narrow highlight in diagnostic to relevent part of line.
+- [ ] page.url and page.permalink are confusing names. Switch to url + page_url
+ - There is confusion over how to do this and whethor or not `| rel_url` should be needed to render these.
+ Requires **careful thought**.
## Documentation
diff --git a/defaults/layouts/home.html b/defaults/layouts/home.html
index 56961ce..5fb7b70 100644
--- a/defaults/layouts/home.html
+++ b/defaults/layouts/home.html
@@ -5,7 +5,7 @@
{{&content}}
- {{#pages}} {{&title}} {{#date}}{{&title}} {{#date}}{{date | format}} {{/date}}
{{/pages}}
diff --git a/defaults/layouts/partials/nav.html b/defaults/layouts/partials/nav.html
index 625642a..620d2b3 100644
--- a/defaults/layouts/partials/nav.html
+++ b/defaults/layouts/partials/nav.html
@@ -1,9 +1,9 @@
diff --git a/defaults/layouts/partials/styles.html b/defaults/layouts/partials/styles.html
index 3f49921..d1dc912 100644
--- a/defaults/layouts/partials/styles.html
+++ b/defaults/layouts/partials/styles.html
@@ -220,4 +220,4 @@ label.margin-toggle:not(.sidenote-number) {
}
}
-{{#params.stylesheets}} {{/params.stylesheets}}
+{{#params.stylesheets}} {{/params.stylesheets}}
diff --git a/defaults/layouts/section_index.html b/defaults/layouts/section_index.html
index 1683eff..92ecf75 100644
--- a/defaults/layouts/section_index.html
+++ b/defaults/layouts/section_index.html
@@ -7,7 +7,7 @@
{{key}}
- {{#items}} {{&title}} {{#date}}{{&title}} {{#date}}{{date | format}} {{/date}}
{{/items}}
diff --git a/mustache/TODOS.md b/mustache/TODOS.md
index cda6f1d..e716ac1 100644
--- a/mustache/TODOS.md
+++ b/mustache/TODOS.md
@@ -4,3 +4,4 @@
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
- [ ] lazy load partials ? (with mutex)
+- [ ] Support changing delimiters (Set Delimiter tag `{{=<% %>=}}`). Delimiters `{{`/`}}`/`{{{`/`}}}` are hardcoded in [tokenizer.odin](./tokenizer.odin); needs an `=` sigil case, mutable open/close delimiters threaded through `tokenize`, and wiring up `spec/specs/delimiters.json` in `spec_test.odin` (fixture present but not run).
diff --git a/mustache/pipes.odin b/mustache/pipes.odin
index 2aad0a8..e357af7 100644
--- a/mustache/pipes.odin
+++ b/mustache/pipes.odin
@@ -24,6 +24,7 @@ Pipe_Op :: enum {
Group_By,
First,
Last,
+ Rel_Url,
}
// Pipe op names are derived from the enum via reflection (lowercased).
@@ -60,10 +61,10 @@ pipe_op_candidates :: proc(allocator := context.temp_allocator) -> []string {
}
Pipe_Filter :: struct {
- op: string,
- args: [dynamic; MAX_PIPE_ARGS]string,
- op_pos: int,
- end_pos: int,
+ op: string,
+ args: [dynamic; MAX_PIPE_ARGS]string,
+ op_pos: int,
+ end_pos: int,
}
Group :: struct {
@@ -345,10 +346,56 @@ apply_filter :: proc(
case .Last:
span := filter.end_pos - filter.op_pos
return take(value, filter.args[:], false, tmpl, filter.op_pos, span)
+ case .Rel_Url:
+ str, ok := reflect.as_string(value)
+ if !ok {
+ return value, Error_Body {
+ msg = "rel_url may only be used on strings",
+ pos = pos,
+ kind = .Data,
+ }
+ }
+ result := rel_url(str, ctx)
+ return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil
}
return {}, nil
}
+// rel_url prefixes a root-relative path (e.g. "/faq/") with the site's
+// base_path so links resolve correctly under a subpath deployment. Values that
+// are already absolute — a scheme ("http://"), protocol-relative ("//"), an
+// in-page fragment ("#"), or a "mailto:" — are returned unchanged, which keeps
+// external menu and footer links intact. base_path is read from the context
+// stack the same way `format` reads date_format/timezone.
+rel_url :: proc(path: string, ctx: []any) -> string {
+ if path == "" {
+ return path
+ }
+ if strings.has_prefix(path, "http://") ||
+ strings.has_prefix(path, "https://") ||
+ strings.has_prefix(path, "//") ||
+ strings.has_prefix(path, "#") ||
+ strings.has_prefix(path, "mailto:") {
+ return path
+ }
+
+ base := ""
+ if raw := resolve_name("base_path", ctx); raw != nil {
+ if s, ok := reflect.as_string(raw); ok {
+ base = s
+ }
+ }
+ if base == "" {
+ return path
+ }
+
+ // Only prefix root-relative paths; leave document-relative ones alone.
+ if path[0] == '/' {
+ return fmt.tprintf("%s%s", base, path)
+ }
+ return path
+}
+
apply_format :: proc(
iso: string,
args: []string,
@@ -386,7 +433,15 @@ apply_format :: proc(
}
// Groups preserve first-appearance order from the input list.
-apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (result: any, err: Error) {
+apply_group_by :: proc(
+ value: any,
+ args: []string,
+ pos: int,
+ span: int,
+) -> (
+ result: any,
+ err: Error,
+) {
if len(args) != 1 {
hint := ""
if len(args) == 0 {
@@ -406,7 +461,12 @@ apply_group_by :: proc(value: any, args: []string, pos: int, span: int) -> (resu
elem_info, count, data := list_info(value)
if elem_info == nil {
- return nil, Error_Body{msg = "group_by expects a list", pos = pos, span = span, kind = .Data}
+ return nil, Error_Body {
+ msg = "group_by expects a list",
+ pos = pos,
+ span = span,
+ kind = .Data,
+ }
}
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
@@ -486,13 +546,13 @@ take :: proc(
n := 1
if !is_list && len(args) == 0 {
warning = Error_Body {
- msg = "defaulting to n=1 for string",
- pos = op_pos,
- span = span,
- kind = .Warning,
+ msg = "defaulting to n=1 for string",
+ pos = op_pos,
+ span = span,
+ kind = .Warning,
source = tmpl.source,
- path = tmpl.path,
- hint = "consider specifying n explicitly",
+ path = tmpl.path,
+ hint = "consider specifying n explicitly",
}
}
if len(args) > 0 {
@@ -525,13 +585,13 @@ take :: proc(
op_name := from_start ? "first" : "last"
alt_name := from_start ? "last" : "first"
warning = Error_Body {
- msg = fmt.tprintf("%s %d is equivalent to %s %d", op_name, n, alt_name, 0 - n),
- pos = op_pos,
- span = span,
- kind = .Warning,
+ msg = fmt.tprintf("%s %d is equivalent to %s %d", op_name, n, alt_name, 0 - n),
+ pos = op_pos,
+ span = span,
+ kind = .Warning,
source = tmpl.source,
- path = tmpl.path,
- hint = "consider using the clearer form",
+ path = tmpl.path,
+ hint = "consider using the clearer form",
}
from_start = !from_start
n = 0 - n
@@ -557,4 +617,3 @@ take :: proc(
return any{new_clone(str[start:end], context.temp_allocator), typeid_of(string)}, warning
}
}
-
diff --git a/site.odin b/site.odin
index 5b03681..2d59964 100644
--- a/site.odin
+++ b/site.odin
@@ -17,6 +17,12 @@ Site_Context :: struct {
title: string,
description: string,
base_url: string,
+ // base_path is the path component of base_url, normalized to a leading slash
+ // with no trailing slash ("" for root-hosted sites). Templates prefix internal
+ // root-relative links with it (directly, or via the `rel_url` pipe) so that
+ // subpath deployments — e.g. project pages at https://user.github.io/proj —
+ // resolve correctly.
+ base_path: string,
params: json.Object,
og: Open_Graph,
menus: map[string][]Menu_Entry,
@@ -127,6 +133,7 @@ init_site :: proc(site: ^Site, flags: Flags) {
}
site_apply_cli_flags(site, flags)
+ site.base_path = site_base_path(site.base_url, site_allocator(site))
site.config_path = path
site.og = og_for_site(site)
@@ -213,6 +220,48 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string)
site.queries = expand_path(config.queries, site_allocator(site))
}
+// site_base_path extracts the path component of base_url, normalized to a
+// leading slash with no trailing slash. Returns "" for root-hosted sites.
+//
+// "https://ex.com/thor/" → "/thor"
+// "https://ex.com/a/b" → "/a/b"
+// "https://ex.com" → ""
+// "http://localhost:8080/" → ""
+site_base_path :: proc(base_url: string, allocator := context.allocator) -> string {
+ url := base_url
+
+ // Strip scheme so the first '/' we find is the start of the path, not the
+ // "//" separating scheme and host.
+ if idx := strings.index(url, "://"); idx >= 0 {
+ url = url[idx + 3:]
+ }
+
+ // The path begins at the first '/' after the host.
+ slash := strings.index_byte(url, '/')
+ if slash < 0 {
+ return ""
+ }
+ path := url[slash:]
+
+ // Drop any query or fragment.
+ if idx := strings.index_byte(path, '?'); idx >= 0 {
+ path = path[:idx]
+ }
+ if idx := strings.index_byte(path, '#'); idx >= 0 {
+ path = path[:idx]
+ }
+
+ // Trim trailing slashes; a bare "/" normalizes to "".
+ for len(path) > 0 && path[len(path) - 1] == '/' {
+ path = path[:len(path) - 1]
+ }
+ if path == "" {
+ return ""
+ }
+
+ return strings.clone(path, allocator)
+}
+
// expand_path replaces a leading ~/ with $HOME/.
expand_path :: proc(path: string, allocator := context.allocator) -> string {
if len(path) >= 2 && path[0] == '~' && path[1] == '/' {
diff --git a/site/thor.json b/site/thor.json
index 6fcfe8e..a76320c 100644
--- a/site/thor.json
+++ b/site/thor.json
@@ -1,6 +1,7 @@
{
"date": {
- "timezone": "America/New_York"
+ "timezone": "America/New_York",
+ "format": "January 2, 2006"
},
"markdown_extensions": {
"footnotes": true