Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(www): add changelog to sidebar as standalone link

- Add NavItem union type supporting both grouped and standalone pages
- Add CHANGELOG.md as first item in sidebar navigation
- Update loader to handle standalone pages with special path for changelog
- Update layout to render standalone items without group header
- Disable minimap for changelog (duplicate heading IDs)
- Move tangled link below search, match link styles
- Add bottom padding to sidebar for scroll breathing room

+487 -95
+315
dev-docs/plans/2025-12-13-changelog-in-sidebar.md
··· 1 + # Changelog in Sidebar Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add the root CHANGELOG.md to the www docs site as a standalone link at the top of the sidebar (no group header). 6 + 7 + **Architecture:** Introduce a `NavItem` union type that supports both grouped pages (`Group(NavGroup)`) and standalone pages (`Page(...)`). Update loader to handle the new type and CHANGELOG.md special path. Update layout to render standalone items without a group header. 8 + 9 + **Tech Stack:** Gleam, Lustre SSG 10 + 11 + --- 12 + 13 + ### Task 1: Add NavItem type to config.gleam 14 + 15 + **Files:** 16 + - Modify: `www/src/www/config.gleam` 17 + 18 + **Step 1: Add NavItem union type after NavGroup** 19 + 20 + Add after line 21: 21 + 22 + ```gleam 23 + /// Navigation item - either a group or standalone page 24 + pub type NavItem { 25 + /// A group with header and multiple pages 26 + Group(NavGroup) 27 + /// A standalone page (no group header) 28 + Page(filename: String, path: String, title: String) 29 + } 30 + ``` 31 + 32 + **Step 2: Update navigation constant to use NavItem** 33 + 34 + Replace the navigation constant (lines 25-63) with: 35 + 36 + ```gleam 37 + /// Sidebar navigation structure 38 + pub const navigation: List(NavItem) = [ 39 + Page("CHANGELOG.md", "/changelog", "Changelog"), 40 + Group(NavGroup( 41 + "Getting Started", 42 + [ 43 + #("README.md", "/", "Introduction"), 44 + #("tutorial.md", "/tutorial", "Tutorial"), 45 + ], 46 + )), 47 + Group(NavGroup( 48 + "Guides", 49 + [ 50 + #("guides/queries.md", "/guides/queries", "Queries"), 51 + #("guides/joins.md", "/guides/joins", "Joins"), 52 + #("guides/mutations.md", "/guides/mutations", "Mutations"), 53 + #("guides/authentication.md", "/guides/authentication", "Authentication"), 54 + #("guides/deployment.md", "/guides/deployment", "Deployment"), 55 + #("guides/patterns.md", "/guides/patterns", "Patterns"), 56 + #( 57 + "guides/troubleshooting.md", 58 + "/guides/troubleshooting", 59 + "Troubleshooting", 60 + ), 61 + ], 62 + )), 63 + Group(NavGroup( 64 + "Reference", 65 + [ 66 + #("reference/aggregations.md", "/reference/aggregations", "Aggregations"), 67 + #( 68 + "reference/subscriptions.md", 69 + "/reference/subscriptions", 70 + "Subscriptions", 71 + ), 72 + #("reference/blobs.md", "/reference/blobs", "Blobs"), 73 + #("reference/variables.md", "/reference/variables", "Variables"), 74 + #("reference/mcp.md", "/reference/mcp", "MCP"), 75 + ], 76 + )), 77 + ] 78 + ``` 79 + 80 + **Step 3: Verify syntax** 81 + 82 + Run: `cd www && gleam build` 83 + Expected: Compilation errors in loader.gleam (expected, will fix in Task 2) 84 + 85 + --- 86 + 87 + ### Task 2: Update loader.gleam for NavItem type 88 + 89 + **Files:** 90 + - Modify: `www/src/www/loader.gleam` 91 + 92 + **Step 1: Update imports** 93 + 94 + Change line 4-6 to import NavItem and its variants: 95 + 96 + ```gleam 97 + import www/config.{ 98 + type DocPage, type NavGroup, type NavItem, DocPage, Group, NavGroup, Page, 99 + docs_dir, navigation, 100 + } 101 + ``` 102 + 103 + **Step 2: Replace load_all function** 104 + 105 + Replace the `load_all` function (lines 9-12) with: 106 + 107 + ```gleam 108 + /// Load all doc pages in configured order 109 + pub fn load_all() -> Result(List(DocPage), String) { 110 + list.try_map(navigation, load_item) 111 + |> result_flatten 112 + } 113 + ``` 114 + 115 + **Step 3: Add load_item function after load_all** 116 + 117 + Add after the new `load_all`: 118 + 119 + ```gleam 120 + /// Load a navigation item (group or standalone page) 121 + fn load_item(item: NavItem) -> Result(List(DocPage), String) { 122 + case item { 123 + Group(group) -> load_group(group) 124 + Page(filename, path, title) -> { 125 + case load_page(filename, path, title, "") { 126 + Ok(page) -> Ok([page]) 127 + Error(e) -> Error(e) 128 + } 129 + } 130 + } 131 + } 132 + ``` 133 + 134 + **Step 4: Refactor load_group to use load_page helper** 135 + 136 + Replace `load_group` function (lines 15-44) with: 137 + 138 + ```gleam 139 + /// Load all pages in a navigation group 140 + fn load_group(group: NavGroup) -> Result(List(DocPage), String) { 141 + let NavGroup(group_name, pages) = group 142 + list.try_map(pages, fn(entry) { 143 + let #(filename, path, title) = entry 144 + load_page(filename, path, title, group_name) 145 + }) 146 + } 147 + ``` 148 + 149 + **Step 5: Add load_page helper function** 150 + 151 + Add after `load_group`: 152 + 153 + ```gleam 154 + /// Load a single page from disk 155 + fn load_page( 156 + filename: String, 157 + path: String, 158 + title: String, 159 + group_name: String, 160 + ) -> Result(DocPage, String) { 161 + let filepath = case filename { 162 + "CHANGELOG.md" -> "../CHANGELOG.md" 163 + _ -> docs_dir <> "/" <> filename 164 + } 165 + 166 + case simplifile.read(filepath) { 167 + Ok(content) -> { 168 + let slug = case path { 169 + "/" -> "index" 170 + _ -> remove_leading_slash(path) 171 + } 172 + Ok(DocPage( 173 + slug: slug, 174 + path: path, 175 + title: title, 176 + group: group_name, 177 + content: content, 178 + )) 179 + } 180 + Error(err) -> 181 + Error( 182 + "Failed to read " 183 + <> filename 184 + <> ": " 185 + <> simplifile.describe_error(err), 186 + ) 187 + } 188 + } 189 + ``` 190 + 191 + **Step 6: Verify build** 192 + 193 + Run: `cd www && gleam build` 194 + Expected: Build succeeds (layout.gleam doesn't need changes - it groups by page.group field, empty string groups will need handling) 195 + 196 + --- 197 + 198 + ### Task 3: Update layout.gleam to handle standalone pages 199 + 200 + **Files:** 201 + - Modify: `www/src/www/layout.gleam` 202 + 203 + **Step 1: Update render_grouped_nav to handle empty group names** 204 + 205 + Replace `render_grouped_nav` function (lines 255-284) with: 206 + 207 + ```gleam 208 + /// Group pages by their group field and render navigation 209 + fn render_grouped_nav( 210 + current_path: String, 211 + pages: List(DocPage), 212 + ) -> List(Element(Nil)) { 213 + pages 214 + |> group_by_group 215 + |> list.map(fn(group) { 216 + let #(group_name, group_pages) = group 217 + case group_name { 218 + "" -> 219 + // Standalone pages - render without group header 220 + html.div( 221 + [attribute.class("sidebar-standalone")], 222 + list.map(group_pages, fn(p) { 223 + let is_active = p.path == current_path 224 + let classes = case is_active { 225 + True -> "active" 226 + False -> "" 227 + } 228 + html.a([attribute.href(p.path), attribute.class(classes)], [ 229 + html.text(p.title), 230 + ]) 231 + }), 232 + ) 233 + _ -> 234 + // Grouped pages - render with header 235 + html.div([attribute.class("sidebar-group")], [ 236 + html.div([attribute.class("sidebar-group-label")], [ 237 + html.text(group_name), 238 + ]), 239 + html.ul( 240 + [], 241 + list.map(group_pages, fn(p) { 242 + let is_active = p.path == current_path 243 + let classes = case is_active { 244 + True -> "active" 245 + False -> "" 246 + } 247 + html.li([], [ 248 + html.a([attribute.href(p.path), attribute.class(classes)], [ 249 + html.text(p.title), 250 + ]), 251 + ]) 252 + }), 253 + ), 254 + ]) 255 + } 256 + }) 257 + } 258 + ``` 259 + 260 + **Step 2: Add CSS for standalone items** 261 + 262 + Modify: `www/static/styles.css` 263 + 264 + Add at end of sidebar styles section: 265 + 266 + ```css 267 + .sidebar-standalone { 268 + margin-bottom: 1.5rem; 269 + } 270 + 271 + .sidebar-standalone a { 272 + display: block; 273 + padding: 0.375rem 0.75rem; 274 + color: var(--text-secondary); 275 + text-decoration: none; 276 + border-radius: 4px; 277 + font-size: 0.875rem; 278 + } 279 + 280 + .sidebar-standalone a:hover { 281 + color: var(--text-primary); 282 + background: var(--hover-bg); 283 + } 284 + 285 + .sidebar-standalone a.active { 286 + color: var(--accent); 287 + background: var(--accent-bg); 288 + } 289 + ``` 290 + 291 + **Step 3: Build and run** 292 + 293 + Run: `cd www && gleam run` 294 + Expected: Build succeeds, generates changelog page 295 + 296 + --- 297 + 298 + ### Task 4: Verify and commit 299 + 300 + **Step 1: Check generated output** 301 + 302 + Run: `ls www/priv/changelog/` 303 + Expected: `index.html` exists 304 + 305 + **Step 2: Inspect sidebar in generated HTML** 306 + 307 + Run: `grep -A5 "sidebar-standalone" www/priv/index.html` 308 + Expected: Shows changelog link without group header 309 + 310 + **Step 3: Commit changes** 311 + 312 + ```bash 313 + git add www/src/www/config.gleam www/src/www/loader.gleam www/src/www/layout.gleam www/static/styles.css 314 + git commit -m "feat(www): add changelog to sidebar as standalone link" 315 + ```
+58 -36
www/src/www/config.gleam
··· 20 20 NavGroup(name: String, pages: List(#(String, String, String))) 21 21 } 22 22 23 + /// Navigation item - either a group or standalone page 24 + pub type NavItem { 25 + /// A group with header and multiple pages 26 + Group(NavGroup) 27 + /// A standalone page (no group header) 28 + Page(filename: String, path: String, title: String) 29 + } 30 + 23 31 /// Sidebar navigation structure 24 - /// Each group contains: #(filename, path, nav_title) 25 - pub const navigation: List(NavGroup) = [ 26 - NavGroup( 27 - "Getting Started", 28 - [ 29 - #("README.md", "/", "Introduction"), 30 - #("tutorial.md", "/tutorial", "Tutorial"), 31 - ], 32 + pub const navigation: List(NavItem) = [ 33 + Page("CHANGELOG.md", "/changelog", "CHANGELOG"), 34 + Group( 35 + NavGroup( 36 + "Getting Started", 37 + [ 38 + #("README.md", "/", "Introduction"), 39 + #("tutorial.md", "/tutorial", "Tutorial"), 40 + ], 41 + ), 32 42 ), 33 - NavGroup( 34 - "Guides", 35 - [ 36 - #("guides/queries.md", "/guides/queries", "Queries"), 37 - #("guides/joins.md", "/guides/joins", "Joins"), 38 - #("guides/mutations.md", "/guides/mutations", "Mutations"), 39 - #("guides/authentication.md", "/guides/authentication", "Authentication"), 40 - #("guides/deployment.md", "/guides/deployment", "Deployment"), 41 - #("guides/patterns.md", "/guides/patterns", "Patterns"), 42 - #( 43 - "guides/troubleshooting.md", 44 - "/guides/troubleshooting", 45 - "Troubleshooting", 46 - ), 47 - ], 43 + Group( 44 + NavGroup( 45 + "Guides", 46 + [ 47 + #("guides/queries.md", "/guides/queries", "Queries"), 48 + #("guides/joins.md", "/guides/joins", "Joins"), 49 + #("guides/mutations.md", "/guides/mutations", "Mutations"), 50 + #( 51 + "guides/authentication.md", 52 + "/guides/authentication", 53 + "Authentication", 54 + ), 55 + #("guides/deployment.md", "/guides/deployment", "Deployment"), 56 + #("guides/patterns.md", "/guides/patterns", "Patterns"), 57 + #( 58 + "guides/troubleshooting.md", 59 + "/guides/troubleshooting", 60 + "Troubleshooting", 61 + ), 62 + ], 63 + ), 48 64 ), 49 - NavGroup( 50 - "Reference", 51 - [ 52 - #("reference/aggregations.md", "/reference/aggregations", "Aggregations"), 53 - #( 54 - "reference/subscriptions.md", 55 - "/reference/subscriptions", 56 - "Subscriptions", 57 - ), 58 - #("reference/blobs.md", "/reference/blobs", "Blobs"), 59 - #("reference/variables.md", "/reference/variables", "Variables"), 60 - #("reference/mcp.md", "/reference/mcp", "MCP"), 61 - ], 65 + Group( 66 + NavGroup( 67 + "Reference", 68 + [ 69 + #( 70 + "reference/aggregations.md", 71 + "/reference/aggregations", 72 + "Aggregations", 73 + ), 74 + #( 75 + "reference/subscriptions.md", 76 + "/reference/subscriptions", 77 + "Subscriptions", 78 + ), 79 + #("reference/blobs.md", "/reference/blobs", "Blobs"), 80 + #("reference/variables.md", "/reference/variables", "Variables"), 81 + #("reference/mcp.md", "/reference/mcp", "MCP"), 82 + ], 83 + ), 62 84 ), 63 85 ] 64 86
+47 -28
www/src/www/layout.gleam
··· 222 222 html.span([attribute.class("sidebar-title")], [html.text("quickslice")]), 223 223 html.span([attribute.class("sidebar-version")], [html.text(version)]), 224 224 ]), 225 - html.a( 226 - [ 227 - attribute.href("https://tangled.org/slices.network/quickslice"), 228 - attribute.class("tangled-link"), 229 - ], 230 - [ 231 - tangled_logo(), 232 - html.span([], [html.text("tangled.org")]), 233 - ], 234 - ), 235 225 html.div([attribute.class("search-container")], [ 236 226 html.input([ 237 227 attribute.attribute("type", "text"), ··· 247 237 [], 248 238 ), 249 239 ]), 240 + html.a( 241 + [ 242 + attribute.href("https://tangled.org/slices.network/quickslice"), 243 + attribute.class("tangled-link"), 244 + ], 245 + [ 246 + tangled_logo(), 247 + html.span([], [html.text("tangled.org")]), 248 + ], 249 + ), 250 250 html.nav([], render_grouped_nav(current_path, pages)), 251 251 ]) 252 252 } ··· 260 260 |> group_by_group 261 261 |> list.map(fn(group) { 262 262 let #(group_name, group_pages) = group 263 - html.div([attribute.class("sidebar-group")], [ 264 - html.div([attribute.class("sidebar-group-label")], [ 265 - html.text(group_name), 266 - ]), 267 - html.ul( 268 - [], 269 - list.map(group_pages, fn(p) { 270 - let is_active = p.path == current_path 271 - let classes = case is_active { 272 - True -> "active" 273 - False -> "" 274 - } 275 - html.li([], [ 263 + case group_name { 264 + "" -> 265 + // Standalone pages - render without group header 266 + html.div( 267 + [attribute.class("sidebar-standalone")], 268 + list.map(group_pages, fn(p) { 269 + let is_active = p.path == current_path 270 + let classes = case is_active { 271 + True -> "active" 272 + False -> "" 273 + } 276 274 html.a([attribute.href(p.path), attribute.class(classes)], [ 277 275 html.text(p.title), 278 - ]), 279 - ]) 280 - }), 281 - ), 282 - ]) 276 + ]) 277 + }), 278 + ) 279 + _ -> 280 + // Grouped pages - render with header 281 + html.div([attribute.class("sidebar-group")], [ 282 + html.div([attribute.class("sidebar-group-label")], [ 283 + html.text(group_name), 284 + ]), 285 + html.ul( 286 + [], 287 + list.map(group_pages, fn(p) { 288 + let is_active = p.path == current_path 289 + let classes = case is_active { 290 + True -> "active" 291 + False -> "" 292 + } 293 + html.li([], [ 294 + html.a([attribute.href(p.path), attribute.class(classes)], [ 295 + html.text(p.title), 296 + ]), 297 + ]) 298 + }), 299 + ), 300 + ]) 301 + } 283 302 }) 284 303 } 285 304
+48 -24
www/src/www/loader.gleam
··· 2 2 import gleam/list 3 3 import simplifile 4 4 import www/config.{ 5 - type DocPage, type NavGroup, DocPage, NavGroup, docs_dir, navigation, 5 + type DocPage, type NavGroup, type NavItem, DocPage, Group, NavGroup, Page, 6 + docs_dir, navigation, 6 7 } 7 8 8 9 /// Load all doc pages in configured order 9 10 pub fn load_all() -> Result(List(DocPage), String) { 10 - list.try_map(navigation, load_group) 11 + list.try_map(navigation, load_item) 11 12 |> result_flatten 13 + } 14 + 15 + /// Load a navigation item (group or standalone page) 16 + fn load_item(item: NavItem) -> Result(List(DocPage), String) { 17 + case item { 18 + Group(group) -> load_group(group) 19 + Page(filename, path, title) -> { 20 + case load_page(filename, path, title, "") { 21 + Ok(page) -> Ok([page]) 22 + Error(e) -> Error(e) 23 + } 24 + } 25 + } 12 26 } 13 27 14 28 /// Load all pages in a navigation group ··· 16 30 let NavGroup(group_name, pages) = group 17 31 list.try_map(pages, fn(entry) { 18 32 let #(filename, path, title) = entry 19 - let filepath = docs_dir <> "/" <> filename 33 + load_page(filename, path, title, group_name) 34 + }) 35 + } 20 36 21 - case simplifile.read(filepath) { 22 - Ok(content) -> { 23 - let slug = case path { 24 - "/" -> "index" 25 - _ -> remove_leading_slash(path) 26 - } 27 - Ok(DocPage( 28 - slug: slug, 29 - path: path, 30 - title: title, 31 - group: group_name, 32 - content: content, 33 - )) 37 + /// Load a single page from disk 38 + fn load_page( 39 + filename: String, 40 + path: String, 41 + title: String, 42 + group_name: String, 43 + ) -> Result(DocPage, String) { 44 + let filepath = case filename { 45 + "CHANGELOG.md" -> "../CHANGELOG.md" 46 + _ -> docs_dir <> "/" <> filename 47 + } 48 + 49 + case simplifile.read(filepath) { 50 + Ok(content) -> { 51 + let slug = case path { 52 + "/" -> "index" 53 + _ -> remove_leading_slash(path) 34 54 } 35 - Error(err) -> 36 - Error( 37 - "Failed to read " 38 - <> filename 39 - <> ": " 40 - <> simplifile.describe_error(err), 41 - ) 55 + Ok(DocPage( 56 + slug: slug, 57 + path: path, 58 + title: title, 59 + group: group_name, 60 + content: content, 61 + )) 42 62 } 43 - }) 63 + Error(err) -> 64 + Error( 65 + "Failed to read " <> filename <> ": " <> simplifile.describe_error(err), 66 + ) 67 + } 44 68 } 45 69 46 70 /// Flatten a Result of List of Lists into Result of List
+5 -1
www/src/www/page.gleam
··· 20 20 |> transform_links 21 21 22 22 // Extract headings BEFORE adding anchor links (regex expects clean headings) 23 - let headings = extract_headings(html_before_anchors) 23 + // Skip minimap for changelog (has duplicate heading IDs like "Added", "Fixed") 24 + let headings = case page.slug { 25 + "changelog" -> [] 26 + _ -> extract_headings(html_before_anchors) 27 + } 24 28 25 29 let html_content = 26 30 html_before_anchors
+14 -6
www/static/styles.css
··· 94 94 95 95 .sidebar { 96 96 width: var(--sidebar-width); 97 - padding: var(--space-5) var(--space-3); 97 + padding: var(--space-5) var(--space-3) 30vh; 98 98 background: var(--bg-base); 99 99 overflow-y: auto; 100 100 flex-shrink: 0; ··· 135 135 gap: var(--space-2); 136 136 padding: var(--space-1) var(--space-3); 137 137 margin-bottom: var(--space-3); 138 - font-size: var(--text-xs); 138 + font-size: var(--text-sm); 139 + font-weight: var(--font-medium); 139 140 color: var(--text-dim); 140 - opacity: 0.6; 141 - transition: opacity 0.15s ease; 141 + text-decoration: none; 142 + border-radius: var(--radius-md); 143 + transition: color 0.15s ease; 142 144 } 143 145 144 146 .tangled-link:hover { 145 - opacity: 1; 147 + color: var(--text-hover); 146 148 } 147 149 148 150 .tangled-link .sidebar-logo { ··· 193 195 text-underline-offset: 4px; 194 196 } 195 197 198 + .sidebar-standalone { 199 + margin-bottom: var(--space-4); 200 + padding-bottom: var(--space-4); 201 + border-bottom: 1px solid var(--border); 202 + } 203 + 196 204 .content { 197 205 flex: 1; 198 206 margin: var(--space-3); ··· 215 223 .content > div::after { 216 224 content: ''; 217 225 display: block; 218 - height: var(--space-16); 226 + height: 30vh; 219 227 } 220 228 221 229 .content h1 {