From c0ff59513f1862af70e5d6ab9e35999b7bb13943 Mon Sep 17 00:00:00 2001
From: Spencer Brower <6729162+sbrow@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:45:49 -0400
Subject: [PATCH] fix: Fixed an issue with stars not showing up.
---
PLAN.md | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
TODOS.md | 4 +-
2 files changed, 143 insertions(+), 2 deletions(-)
create mode 100644 PLAN.md
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..d1657ee
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,141 @@
+# 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
+
+```handlebars
+{{#posts.by_year}}
+
{{year}}
+ {{#posts}}
+ {{title}}
+ {{/posts}}
+{{/posts.by_year}}
+
+{{#posts.all}}
+ {{title}}
+{{/posts.all}}
+```
+
+Context resolution handles naming naturally — `posts` inside a Year_Group (the `.posts` field) shadows the outer `posts` view object.
+
+## Data Model
+
+```odin
+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:
+
+```odin
+// 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., `all` field on `Pages_View`). The proc accesses it via the struct instance.
+- Use `context.allocator` to store captured data that the closure references.
+- Pass the struct as an implicit `self` parameter (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`
+
+```odin
+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_Section` struct
+- Remove `year_sections` from `Section_Data`
+- Remove year grouping logic from `render_section`
+- Delete `get_year` helper (or move it into the closure)
+
+### 5. Update templates
+
+`posts_index.html`:
+```handlebars
+{{#posts.by_year}}
+
+ {{year}}
+
+ {{#posts}} - ...
{{/posts}}
+
+
+{{/posts.by_year}}
+```
+
+Home page could use the same pattern:
+```handlebars
+{{#pages.all}}
+ ...
+{{/pages.all}}
+```
+
+### 6. Tests
+
+- Verify `by_year` proc 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 starred
+- `posts.recent(n)` — most recent N posts (if we support params)
+- `site.tags` — all unique tags across posts
+- `pages.by_section("posts")` — filter by section
+
+These are all zero-argument procs on view structs. No mustache changes needed beyond step 1.
diff --git a/TODOS.md b/TODOS.md
index f728cd6..9706608 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -14,7 +14,7 @@
- [ ] ensure sidenote numbers render in display order and not in declaration order.
- [ ] OpenGraph meta tags — verify all fields match production site
- [ ] Add Opt-in deflist support.
-- [ ] We need to be able to do `Year_Section` in a non-magical, unpriviledged way.
+- [ ] We need to be able to do `Year_Section` in a non-magical, unprivileged way. See [PLAN.md](PLAN.md) for computed properties approach.
- [ ] set opengraph tags / description automatically if unset. (Like hugo does)
- [ ] Table of contents support.
- [ ] prefixing every log message with `thor: ` is silly
@@ -46,7 +46,7 @@
- [ ] Markdown
- [ ] Add overloads for every extension - accept ^strings.Builder.
- [ ] Add conventional (Hugo style) footnotes option.
- - [ ] Add header ids
+ - [ ] Add heading ids as a default on extension.
- [ ] Review every file in thor
- [ ] Review assets.odin
- [ ] Review content.odin