feat: Added tools feature for CSS pre/post processors.

This commit is contained in:
Spencer Brower
2026-09-08 13:21:58 -04:00
parent 2f997e89bb
commit 122627c51c
8 changed files with 535 additions and 12 deletions
+33 -6
View File
@@ -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)
}
+30 -2
View File
@@ -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
}
}
+276
View File
@@ -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(&reg.mu)
defer sync.unlock(&reg.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
}
+154
View File
@@ -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 = &reg)
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 = &reg)
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 = &reg)
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(&reg, "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(&reg, "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(&reg, "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 = &reg)
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(&reg, "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")
}