4.2 KiB
Plan: Computed Properties in Mustache
Problem
Year_Section is a privileged, hardcoded struct in render.odin. Every section listing gets pages grouped by year — no choice. The grouping dimension (year) is baked into the code. Templates can't request different views of the same data.
Solution: Computed Properties
Struct fields that are procs get called during mustache data resolution. When lookup_in resolves a field via reflection and finds a proc, it calls it and uses the return value. This is computed properties (Vue/Ember pattern), not spec lambdas (text transformers).
No mustache syntax changes. {{#posts.by_year}} is vanilla mustache — the magic is in data resolution, not template syntax.
Template Usage
{{#posts.by_year}}
<h2>{{year}}</h2>
{{#posts}}
<li>{{title}}</li>
{{/posts}}
{{/posts.by_year}}
{{#posts.all}}
<li>{{title}}</li>
{{/posts.all}}
Context resolution handles naming naturally — posts inside a Year_Group (the .posts field) shadows the outer posts view object.
Data Model
Year_Group :: struct {
year: string,
posts: [dynamic]Page_Context,
}
Pages_View :: struct {
all: [dynamic]Page_Context,
by_year: proc() -> [dynamic]Year_Group,
}
Section_Data :: struct {
using base: Base_Data,
page_title: string,
posts: Pages_View,
}
by_year is a closure that captures all and groups lazily. Grouping only happens when the template requests it.
Implementation Steps
1. Mustache data layer (mustache/data.odin)
In lookup_in (or resolve_name), after resolving a field value via reflection:
// If the resolved value is a proc, call it
if value, ok := resolved.(proc() -> any); ok {
return value()
}
Need to handle proc detection generically — Odin has many proc types (different signatures, calling conventions). The computed properties we use are all zero-argument procs returning any.
Alternative: check via reflect.Type_Info if the value is a proc type, then call it through reflect.CallProcedure or transmute.
2. Odin closure challenge
Contextless procs can't capture variables. Regular closure procs capture context by reference. We need the proc to access the page list when called later by mustache.
Options:
- Store the data alongside the proc in the struct (e.g.,
allfield onPages_View). The proc accesses it via the struct instance. - Use
context.allocatorto store captured data that the closure references. - Pass the struct as an implicit
selfparameter (method-style).
The simplest approach: the closure captures a pointer to the data, which is arena-allocated and alive during rendering.
3. Build Pages_View in render_section
all_pages := make([dynamic]Page_Context)
for page in site.pages {
if page.section != section || page._is_index { continue }
append(&all_pages, build_page_context(page))
}
view := Pages_View{
all = all_pages,
by_year = group_by_year_closure, // captures all_pages
}
4. Remove Year_Section and year_sections
- Delete
Year_Sectionstruct - Remove
year_sectionsfromSection_Data - Remove year grouping logic from
render_section - Delete
get_yearhelper (or move it into the closure)
5. Update templates
posts_index.html:
{{#posts.by_year}}
<section>
<h2>{{year}}</h2>
<ul class="post-list">
{{#posts}} <li>...</li> {{/posts}}
</ul>
</section>
{{/posts.by_year}}
Home page could use the same pattern:
{{#pages.all}}
<li>...</li>
{{/pages.all}}
6. Tests
- Verify
by_yearproc is called lazily (only when template requests it) - Verify year groups contain correct pages
- Verify flat list (
all) works independently
Prerequisite
Step 1 (mustache data layer) must be done first. This is the foundation — without proc-calling in data resolution, none of the rest works.
Future Extensions
Once computed properties work, they're available everywhere:
posts.featured— filter by starredposts.recent(n)— most recent N posts (if we support params)site.tags— all unique tags across postspages.by_section("posts")— filter by section
These are all zero-argument procs on view structs. No mustache changes needed beyond step 1.