From 122627c51c329f36d6d79e4e37470ccb580c7c5a Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:37:00 -0400 Subject: [PATCH] feat: Added `tools` feature for CSS pre/post processors. --- TODOS.md | 5 + assets.odin | 6 + mustache/mustache.odin | 39 +++++- mustache/pipes.odin | 32 ++++- mustache/tools.odin | 276 +++++++++++++++++++++++++++++++++++++++ mustache/tools_test.odin | 154 ++++++++++++++++++++++ render.odin | 24 +++- site.odin | 11 ++ 8 files changed, 535 insertions(+), 12 deletions(-) create mode 100644 mustache/tools.odin create mode 100644 mustache/tools_test.odin diff --git a/TODOS.md b/TODOS.md index a4eb9c6..74d7e8e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -25,6 +25,11 @@ suppress the auto-generated menu while still honoring frontmatter. - [ ] asset css files not automatically included in site. - should we at least warn about them? +- [ ] Review the whole `copy_assets_dir` pipeline. It copies every file under + `assets/` to the output dir blindly, whether or not anything references it, + and now also skips dests that already exist (a blunt collision guard so the + raw copy doesn't clobber `tool`-pipe output written earlier in the build). + Reconsider tracking which assets are actually used/published instead. - [ ] warn if stylesheet url starts with /assets (and is therefore invalid). - [ ] warn when heading levels are skipped in a toc. - [ ] extract `[TOC]` from `.md` body (or at least from the start.) diff --git a/assets.odin b/assets.odin index 49f9f12..dc44f3e 100644 --- a/assets.odin +++ b/assets.odin @@ -27,6 +27,12 @@ copy_assets_dir :: proc(vfs: ^VFS, output_dir: string, features: bit_set[Feature rel := virtual_path[len("assets/"):] dest := fmt.tprintf("%s/%s", output_dir, rel) + // Don't clobber a file already written this build (e.g. the output of a + // `tool` pipe, which publishes to the same dest path as its source). + if os.exists(dest) { + continue + } + if idx := strings.last_index(dest, "/"); idx >= 0 { if err := os.make_directory_all(dest[:idx]); err != nil && err != .Exist { log.warnf("cannot create %s: %v", dest[:idx], err) diff --git a/mustache/mustache.odin b/mustache/mustache.odin index ce18864..cbc688b 100644 --- a/mustache/mustache.odin +++ b/mustache/mustache.odin @@ -182,6 +182,7 @@ render :: proc( partials: map[string]Template = nil, allocator := context.allocator, warnings: ^[dynamic]Error = nil, + tools: ^Tool_Registry = nil, ) -> ( result: string, err: Error, @@ -207,7 +208,15 @@ render :: proc( } all_nodes := tmpl.nodes[:] - err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder, warnings = warnings) + err = render_nodes( + tmpl, + all_nodes, + &ctx, + partials, + &builder, + warnings = warnings, + tools = tools, + ) if err != nil { return result, err } @@ -588,6 +597,7 @@ render_template :: proc( blocks: map[string]Block_Override, indent: string, warnings: ^[dynamic]Error = nil, + tools: ^Tool_Registry = nil, ) -> Error { if len(indent) > 0 { state := Indent_State { @@ -595,9 +605,9 @@ render_template :: proc( at_line_start = false, } strings.write_string(b, indent) // first line always gets indent - return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state, warnings) + return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state, warnings, tools) } - return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil, warnings) + return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil, warnings, tools) } write_indented :: proc( @@ -642,6 +652,7 @@ render_nodes :: proc( blocks: map[string]Block_Override = nil, indent_state: ^Indent_State = nil, warnings: ^[dynamic]Error = nil, + tools: ^Tool_Registry = nil, ) -> Error { i := 0 for i < len(nodes) { @@ -672,6 +683,7 @@ render_nodes :: proc( ctx[:], current, warnings, + tools, ) if perr != nil { return tag_error(perr, current) @@ -698,6 +710,7 @@ render_nodes :: proc( ctx[:], current, warnings, + tools, ) if perr != nil { return tag_error(perr, current) @@ -720,6 +733,7 @@ render_nodes :: proc( ctx[:], current, warnings, + tools, ) if perr != nil { return tag_error(perr, current) @@ -743,6 +757,7 @@ render_nodes :: proc( blocks, indent_state, warnings, + tools, ) or_return } } else { @@ -775,6 +790,7 @@ render_nodes :: proc( ctx[:], current, warnings, + tools, ) if perr != nil { return tag_error(perr, current) @@ -791,6 +807,7 @@ render_nodes :: proc( blocks, indent_state, warnings, + tools, ) or_return } i += 1 + len(node.children) @@ -805,7 +822,7 @@ render_nodes :: proc( if !found { warn_missing_partial(current, partials, node, name) } else { - render_template(pt, ctx, partials, b, nil, node.indent, warnings) or_return + render_template(pt, ctx, partials, b, nil, node.indent, warnings, tools) or_return if indent_state != nil { indent_state.at_line_start = false } @@ -841,6 +858,7 @@ render_nodes :: proc( content_blocks, nil, warnings, + tools, ) or_return at_ls := true write_indented(b, node.indent, strings.to_string(temp), &at_ls) @@ -854,6 +872,7 @@ render_nodes :: proc( content_blocks, indent_state, warnings, + tools, ) or_return } i += 1 + len(node.children) @@ -866,7 +885,16 @@ render_nodes :: proc( warn_missing_partial(current, partials, node, node.key) } else { warn_unmatched_block_overrides(current, pt, parent_children) - render_template(pt, ctx, partials, b, merged, node.indent, warnings) or_return + render_template( + pt, + ctx, + partials, + b, + merged, + node.indent, + warnings, + tools, + ) or_return if indent_state != nil { indent_state.at_line_start = false } @@ -1057,4 +1085,3 @@ warn_context_depth :: proc(current: Template, node: Node) { ) log.warnf("%s", diag) } - diff --git a/mustache/pipes.odin b/mustache/pipes.odin index 0821eac..8d8c40a 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -25,6 +25,7 @@ Pipe_Op :: enum { First, Last, Rel_Url, + Tool, } // Pipe op names are derived from the enum via reflection (lowercased). @@ -225,13 +226,14 @@ apply_pipeline :: proc( ctx: []any, tmpl: Template, warnings: ^[dynamic]Error = nil, + tools: ^Tool_Registry = nil, ) -> ( current: any, err: Error, ) { current = value for &filter in filters { - result, ferr := apply_filter(current, &filter, pos, ctx, tmpl) + result, ferr := apply_filter(current, &filter, pos, ctx, tmpl, tools) if ferr != nil { b := body(ferr) if b.kind != .Warning { @@ -278,6 +280,7 @@ apply_filter :: proc( pos: int, ctx: []any, tmpl: Template, + tools: ^Tool_Registry = nil, ) -> ( any, Error, @@ -358,6 +361,32 @@ apply_filter :: proc( } result := rel_url(str, ctx) return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil + case .Tool: + if len(filter.args) != 1 { + return value, Error_Body { + msg = fmt.tprintf( + "tool expects 1 argument (the tool name), got %d", + len(filter.args), + ), + pos = filter.op_pos, + span = filter.end_pos - filter.op_pos, + kind = .Data, + } + } + bv, _ := base_value(value) + str, ok := reflect.as_string(bv) + if !ok { + return value, Error_Body { + msg = "tool may only be used on strings", + pos = pos, + kind = .Data, + } + } + result, terr := run_tool(tools, filter.args[0], str, filter.op_pos) + if terr != nil { + return value, terr + } + return any{new_clone(result, context.temp_allocator), typeid_of(string)}, nil } return {}, nil } @@ -618,4 +647,3 @@ take :: proc( return any{new_clone(str[start:end], context.temp_allocator), typeid_of(string)}, warning } } - diff --git a/mustache/tools.odin b/mustache/tools.odin new file mode 100644 index 0000000..8a8f124 --- /dev/null +++ b/mustache/tools.odin @@ -0,0 +1,276 @@ +package mustache + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:sync" + +// The `tool` pipe runs a user-configured external program over a site asset. +// A tool is a command string that is itself a mustache template; rendering it +// yields the command line, which is tokenized into argv and run via +// os.process_exec (no shell). The command template has three variables: +// +// {{in}} absolute path to the source file the tool should read +// {{out}} absolute path the tool should write its result to +// {{#minify}} section, non-empty when the site is building with minify on +// +// The tool does its own file I/O through {{in}}/{{out}}; neither mustache nor +// the host shuffles bytes. The pipe takes an asset path (e.g. "/css/main.css"), +// runs the tool, and returns the root-relative URL of the written output. + +// Tool_Registry is the host-supplied configuration for the `tool` pipe. The +// host (thor) owns one per build and threads a pointer to it through render, so +// it never enters the template context stack and can't be resolved as a key. +// The config fields are written once before rendering and only read thereafter; +// `manifest` is the sole mutable state and is guarded by `mu` — needed because +// render may go parallel and because os.process_start is not thread-safe. +Tool_Registry :: struct { + commands: map[string]string, // tool name -> command template + input_dir: string, // base dir for resolving {{in}} (the assets dir) + output_dir: string, // base dir for the written output (the output dir) + minify: bool, + manifest: map[string]string, // (name\x00input) -> output URL, per build + mu: sync.Mutex, +} + +// tool_registry_init prepares `reg` for one build: the input/output base dirs, +// the minify flag, and a fresh (empty) manifest. `commands` is configured +// separately (from thor.json) and left untouched. The manifest is temp-allocated +// — build-scoped, discarded when the build's temp allocator is reset. Nothing +// needs to be cleaned up between builds: the host recreates the whole registry. +tool_registry_init :: proc(reg: ^Tool_Registry, input_dir, output_dir: string, minify: bool) { + reg.input_dir = input_dir + reg.output_dir = output_dir + reg.minify = minify + reg.manifest = make(map[string]string, context.temp_allocator) +} + +// run_tool executes the named tool over `input` (an asset path) and returns the +// root-relative URL of the produced file. Any failure is a hard error (.Data). +run_tool :: proc( + reg: ^Tool_Registry, + name: string, + input: string, + pos: int, +) -> ( + url: string, + err: Error, +) { + if reg == nil { + return "", Error_Body{msg = "tool pipe is not available here", pos = pos, kind = .Data} + } + + // Absolute URLs (CDN links, protocol-relative) have no local source; pass + // them through so a mixed stylesheet list stays safe. + if is_absolute_url(input) { + return input, nil + } + + cmd_tmpl, ok := reg.commands[name] + if !ok { + return "", Error_Body { + msg = fmt.tprintf("unknown tool '%s' (add it to thor.json \"tools\")", name), + pos = pos, + kind = .Data, + } + } + + // Logical path -> in/out/url. Strip a single leading '/'. + rel := input + if len(rel) > 0 && rel[0] == '/' { + rel = rel[1:] + } + if rel == "" { + return "", Error_Body{msg = "tool: empty asset path", pos = pos, kind = .Data} + } + in_path := fmt.tprintf("%s/%s", reg.input_dir, rel) + out_path := fmt.tprintf("%s/%s", reg.output_dir, rel) + out_url := fmt.tprintf("/%s", rel) + + if !os.exists(in_path) { + return "", Error_Body { + msg = fmt.tprintf("tool '%s': source file not found: %s", name, in_path), + pos = pos, + kind = .Data, + } + } + + // Render the command template. Done OUTSIDE the lock so a command template + // that (mistakenly) uses a `tool` pipe can't deadlock on reg.mu. The + // registry fields it reads are immutable during rendering. + cmd_str, rerr := render_command(cmd_tmpl, in_path, out_path, reg.minify, pos) + if rerr != nil { + return "", rerr + } + + argv := tokenize_command(cmd_str, context.temp_allocator) + if len(argv) == 0 { + return "", Error_Body { + msg = fmt.tprintf("tool '%s': command is empty", name), + pos = pos, + kind = .Data, + } + } + if !find_on_path(argv[0]) { + return "", Error_Body { + msg = fmt.tprintf("tool '%s': program '%s' not found on PATH", name, argv[0]), + pos = pos, + kind = .Data, + } + } + + sync.lock(®.mu) + defer sync.unlock(®.mu) + + key := fmt.tprintf("%s\x00%s", name, input) + if cached, hit := reg.manifest[key]; hit { + return cached, nil + } + + // Ensure the output's parent dir exists so the tool can write {{out}}. + if idx := strings.last_index(out_path, "/"); idx >= 0 { + if merr := os.make_directory_all(out_path[:idx]); merr != nil && merr != .Exist { + return "", Error_Body { + msg = fmt.tprintf("tool '%s': cannot create %s: %v", name, out_path[:idx], merr), + pos = pos, + kind = .Data, + } + } + } + + state, _, stderr, xerr := os.process_exec( + os.Process_Desc{command = argv}, + context.temp_allocator, + ) + if xerr != nil { + return "", Error_Body { + msg = fmt.tprintf("tool '%s': failed to run '%s': %v", name, argv[0], xerr), + pos = pos, + kind = .Data, + } + } + if state.exit_code != 0 { + return "", Error_Body { + msg = fmt.tprintf( + "tool '%s': '%s' exited %d\n%s", + name, + argv[0], + state.exit_code, + strings.trim_space(string(stderr)), + ), + pos = pos, + kind = .Data, + } + } + + // Memoize for the rest of this build. `key` and `out_url` are already on the + // temp allocator — the same build-scoped lifetime as the manifest — so they + // are stored directly, no clone needed. + if reg.manifest == nil { + reg.manifest = make(map[string]string, context.temp_allocator) + } + reg.manifest[key] = out_url + return out_url, nil +} + +// render_command renders a tool's command template with the in/out/minify +// context. Paths render through mustache's default HTML escaping; the paths we +// generate are controlled and contain no HTML-special characters, so this is +// harmless in practice. +render_command :: proc( + tmpl_src, in_path, out_path: string, + minify: bool, + pos: int, +) -> ( + string, + Error, +) { + tmpl, perr := parse(tmpl_src, "", context.temp_allocator, context.temp_allocator) + if perr != nil { + b := body(perr) + return "", Error_Body { + msg = fmt.tprintf("invalid tool command template: %s", b.msg), + pos = pos, + kind = .Data, + } + } + // `in`/`out` are Odin keywords, so the context is a map rather than a struct. + data := make(map[string]any, 3, context.temp_allocator) + data["in"] = in_path + data["out"] = out_path + data["minify"] = minify + out, rerr := render(tmpl, data, allocator = context.temp_allocator) + if rerr != nil { + b := body(rerr) + return "", Error_Body { + msg = fmt.tprintf("failed to render tool command: %s", b.msg), + pos = pos, + kind = .Data, + } + } + return out, nil +} + +// is_absolute_url reports whether a path points outside the local site. +is_absolute_url :: proc(s: string) -> bool { + return( + strings.has_prefix(s, "http://") || + strings.has_prefix(s, "https://") || + strings.has_prefix(s, "//") \ + ) +} + +// tokenize_command splits a command line into argv on whitespace, treating a +// double-quoted span as a single token with the quotes stripped. No escape +// sequences or single quotes (kept deliberately minimal — commands come from a +// trusted config, not untrusted input). +tokenize_command :: proc(cmd: string, allocator := context.temp_allocator) -> []string { + out := make([dynamic]string, 0, 8, allocator) + i := 0 + for i < len(cmd) { + for i < len(cmd) && is_pipe_space(cmd[i]) { + i += 1 + } + if i >= len(cmd) { + break + } + if cmd[i] == '"' { + j := i + 1 + for j < len(cmd) && cmd[j] != '"' { + j += 1 + } + append(&out, strings.clone(cmd[i + 1:j], allocator)) + i = j + 1 if j < len(cmd) else j + } else { + start := i + for i < len(cmd) && !is_pipe_space(cmd[i]) { + i += 1 + } + append(&out, strings.clone(cmd[start:i], allocator)) + } + } + return out[:] +} + +// find_on_path reports whether `prog` is executable: an explicit path is checked +// directly, otherwise each PATH entry is probed. +find_on_path :: proc(prog: string) -> bool { + if strings.contains(prog, "/") { + return os.exists(prog) + } + path := os.get_env_alloc("PATH", context.temp_allocator) + if path == "" { + return false + } + sep := ";" when ODIN_OS == .Windows else ":" + for dir in strings.split_iterator(&path, sep) { + if dir == "" { + continue + } + candidate := fmt.tprintf("%s/%s", dir, prog) + if os.exists(candidate) { + return true + } + } + return false +} diff --git a/mustache/tools_test.odin b/mustache/tools_test.odin new file mode 100644 index 0000000..10b3333 --- /dev/null +++ b/mustache/tools_test.odin @@ -0,0 +1,154 @@ +#+test +package mustache + +import "core:fmt" +import "core:os" +import "core:strings" +import "core:testing" + +// tool_test_dirs makes a fresh in/out dir pair under the system temp dir and +// returns their paths (unique per name). +tool_test_dirs :: proc(name: string) -> (in_dir, out_dir: string) { + base, _ := os.temp_directory(context.temp_allocator) + in_dir = fmt.tprintf("%s/thor_tool_test/%s/in", base, name) + out_dir = fmt.tprintf("%s/thor_tool_test/%s/out", base, name) + os.make_directory_all(in_dir) + os.make_directory_all(out_dir) + return +} + +// Tool_Test_Ctx carries the asset path as a context value (mustache resolves +// tag keys from the context, not from quoted literals). +Tool_Test_Ctx :: struct { + p: string, +} + +@(test) +test_tool_unknown_tool :: proc(t: ^testing.T) { + reg: Tool_Registry // no commands configured + tpl, _ := parse(`{{ p | tool nope }}`) + defer delete_template(&tpl) + _, err := render(tpl, Tool_Test_Ctx{p = "/a.css"}, tools = ®) + testing.expect(t, err != nil, "unknown tool should be a hard error") +} + +@(test) +test_tool_unavailable_without_registry :: proc(t: ^testing.T) { + // No registry threaded through (tools == nil): the pipe must fail rather + // than silently do nothing. + tpl, _ := parse(`{{ p | tool anything }}`) + defer delete_template(&tpl) + _, err := render(tpl, Tool_Test_Ctx{p = "/a.css"}) + testing.expect(t, err != nil, "tool pipe without a registry should error") +} + +@(test) +test_tool_arg_count :: proc(t: ^testing.T) { + reg: Tool_Registry + + // Zero args. + tpl0, _ := parse(`{{ p | tool }}`) + defer delete_template(&tpl0) + _, err0 := render(tpl0, Tool_Test_Ctx{p = "/a.css"}, tools = ®) + testing.expect(t, err0 != nil, "tool with no name should error") + + // Two args. + tpl2, _ := parse(`{{ p | tool one two }}`) + defer delete_template(&tpl2) + _, err2 := render(tpl2, Tool_Test_Ctx{p = "/a.css"}, tools = ®) + testing.expect(t, err2 != nil, "tool with two args should error") +} + +@(test) +test_tool_absolute_url_passthrough :: proc(t: ^testing.T) { + // A CDN URL has no local source; it should pass through untouched. + reg: Tool_Registry + url, err := run_tool(®, "anything", "https://cdn.example.com/x.css", 0) + testing.expect(t, err == nil, "absolute URL should not error") + testing.expect_value(t, url, "https://cdn.example.com/x.css") +} + +@(test) +test_tool_source_not_found :: proc(t: ^testing.T) { + in_dir, out_dir := tool_test_dirs("missing_src") + commands := make(map[string]string, context.temp_allocator) + commands["cp"] = "cp {{in}} {{out}}" + reg := Tool_Registry { + commands = commands, + input_dir = in_dir, + output_dir = out_dir, + } + + _, err := run_tool(®, "cp", "/nope.css", 0) + testing.expect(t, err != nil, "missing source file should be a hard error") +} + +@(test) +test_tool_missing_binary :: proc(t: ^testing.T) { + in_dir, out_dir := tool_test_dirs("missing_bin") + _ = os.write_entire_file_from_string(fmt.tprintf("%s/a.css", in_dir), "body{}") + + commands := make(map[string]string, context.temp_allocator) + commands["ghost"] = "thor-tool-does-not-exist-xyz {{in}} {{out}}" + reg := Tool_Registry { + commands = commands, + input_dir = in_dir, + output_dir = out_dir, + } + + _, err := run_tool(®, "ghost", "/a.css", 0) + testing.expect(t, err != nil, "missing binary should be caught by PATH check") +} + +@(test) +test_tool_runs :: proc(t: ^testing.T) { + when ODIN_OS != .Windows { + in_dir, out_dir := tool_test_dirs("runs") + os.make_directory_all(fmt.tprintf("%s/css", in_dir)) + _ = os.write_entire_file_from_string( + fmt.tprintf("%s/css/main.css", in_dir), + "body { color: red }", + ) + + commands := make(map[string]string, context.temp_allocator) + commands["copyit"] = "cp {{in}} {{out}}" + reg := Tool_Registry { + commands = commands, + input_dir = in_dir, + output_dir = out_dir, + } + + tpl, _ := parse(`{{ p | tool copyit }}`) + defer delete_template(&tpl) + result, err := render(tpl, Tool_Test_Ctx{p = "/css/main.css"}, tools = ®) + defer delete(result) + testing.expect(t, err == nil, "cp tool should succeed") + testing.expect_value(t, result, "/css/main.css") + + // The tool wrote the output file. + written := fmt.tprintf("%s/css/main.css", out_dir) + data, rerr := os.read_entire_file_from_path(written, context.temp_allocator) + testing.expect(t, rerr == nil, "output file should exist") + testing.expect_value(t, string(data), "body { color: red }") + + // The result was memoized; a second call for the same asset returns the + // same URL from the registry (mutated through the pointer). + url2, err2 := run_tool(®, "copyit", "/css/main.css", 0) + testing.expect(t, err2 == nil, "memoized call should not error") + testing.expect_value(t, url2, "/css/main.css") + testing.expect(t, len(reg.manifest) == 1, "result should be memoized") + } +} + +@(test) +test_tool_minify_section :: proc(t: ^testing.T) { + // The command template is itself mustache: {{#minify}} should expand only + // when the registry has minify on. Verify via the rendered command string. + cmd, err := render_command("prog {{#minify}}--min{{/minify}} {{in}}", "/a", "/b", true, 0) + testing.expect(t, err == nil, "render_command should succeed (minify on)") + testing.expect(t, strings.contains(cmd, "--min"), "minify section should render when on") + + cmd2, err2 := render_command("prog {{#minify}}--min{{/minify}} {{in}}", "/a", "/b", false, 0) + testing.expect(t, err2 == nil, "render_command should succeed (minify off)") + testing.expect(t, !strings.contains(cmd2, "--min"), "minify section should be empty when off") +} diff --git a/render.odin b/render.odin index 8607165..58a1d24 100644 --- a/render.odin +++ b/render.odin @@ -139,8 +139,14 @@ render_template :: proc( ctx: Template_Context, partials: map[string]mustache.Template, reported_errors: ^map[string]bool, + tools: ^mustache.Tool_Registry, ) -> string { - result, err := mustache.render(content_tpl, []any{ctx.site, ctx.page, ctx}, partials) + result, err := mustache.render( + content_tpl, + []any{ctx.site, ctx.page, ctx}, + partials, + tools = tools, + ) if err != nil { formatted := mustache.format_render_error( err, @@ -185,6 +191,16 @@ render_site :: proc(site: ^Site) { timezone = site.tz, } + // Prepare the `tool` pipe registry for this build (commands were filled from + // thor.json at config time). A pointer to it is threaded into every + // mustache.render call. + mustache.tool_registry_init( + &site.tool_registry, + site.assets_dir, + site.output_dir, + .Minify in site.features, + ) + // Find home page home: Page has_home := false @@ -317,7 +333,7 @@ render_page_html :: proc( ctx.page = page ctx.og = og_for_page(site.og, page) ctx.params = merge_params(site.params, page.params) - return render_template(content_tpl, ctx, partials, seen) + return render_template(content_tpl, ctx, partials, seen, &site.tool_registry) } render_home_html :: proc( @@ -341,7 +357,7 @@ render_home_html :: proc( ctx.og = og_for_page(site.og, home) ctx.params = merge_params(site.params, home.params) - return render_template(content_tpl, ctx, partials, seen) + return render_template(content_tpl, ctx, partials, seen, &site.tool_registry) } render_section :: proc( @@ -380,7 +396,7 @@ render_section :: proc( } ctx.posts = posts ctx.params = merge_params(site.params, ctx.page.params) - return render_template(content_tpl, ctx, partials, seen) + return render_template(content_tpl, ctx, partials, seen, &site.tool_registry) } load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template { diff --git a/site.odin b/site.odin index 2d59964..a0e6965 100644 --- a/site.odin +++ b/site.odin @@ -10,6 +10,7 @@ import "core:time/datetime" import "core:time/timezone" import md "markdown" +import "mustache" // Site_Context holds the site date that is accessible in templates. @@ -48,6 +49,11 @@ Site :: struct { tz: ^datetime.TZ_Region, grammars: string, queries: string, + // tool_registry is the `tool` pipe's state: `commands` is filled from + // thor.json at config time; the per-build I/O dirs, minify flag, and memo + // are set by mustache.tool_registry_init in render_site. A pointer is + // threaded into mustache.render. + tool_registry: mustache.Tool_Registry, } Date_Preferences :: struct { @@ -79,6 +85,7 @@ Config_File :: struct { date: Date_Preferences, grammars: string, queries: string, + tools: map[string]string, } // Configuration loaded from command line arguments. Gets folded in to Site @@ -218,6 +225,10 @@ site_apply_config :: proc(site: ^Site, config: Config_File, config_dir: string) site.grammars = expand_path(config.grammars, site_allocator(site)) site.queries = expand_path(config.queries, site_allocator(site)) + + if config.tools != nil { + site.tool_registry.commands = config.tools + } } // site_base_path extracts the path component of base_url, normalized to a