├── .github ├── FUNDING.yml └── workflows │ └── golangci-lint.yml ├── .gitignore ├── ADVANCED.md ├── INTEGRATIONS.md ├── LICENSE ├── README.md ├── connector ├── alpine-ajax.go ├── alpine.go ├── connector.go ├── htmx.go ├── partial.go ├── stimulus.go ├── turbo.go ├── unpoly.go └── vuejs.go ├── examples ├── form │ ├── main.go │ └── templates │ │ ├── footer.gohtml │ │ ├── form.gohtml │ │ ├── index.gohtml │ │ └── submitted.gohtml ├── infinitescroll │ ├── main.go │ └── templates │ │ ├── content.gohtml │ │ ├── footer.gohtml │ │ ├── index.gohtml │ │ └── rickrolled.gohtml ├── tabs-htmx │ ├── content.gohtml │ ├── footer.gohtml │ ├── index.gohtml │ ├── main.go │ ├── tab1.gohtml │ ├── tab2.gohtml │ └── tab3.gohtml └── tabs │ ├── content.gohtml │ ├── footer.gohtml │ ├── index.gohtml │ ├── main.go │ ├── tab1.gohtml │ ├── tab2.gohtml │ ├── tab3.gohtml │ └── templates │ ├── content.gohtml │ ├── footer.gohtml │ ├── index.gohtml │ ├── tab1.gohtml │ ├── tab2.gohtml │ └── tab3.gohtml ├── go.mod ├── in_memory_fs.go ├── js ├── README.md ├── partial.htmx.js └── partial.js ├── partial.go ├── partial_test.go ├── service.go ├── template_functions.go ├── template_functions_test.go ├── tree.go └── tree_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: donseba # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | # pull-requests: read 13 | 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.22' 23 | cache: false 24 | - name: golangci-lint 25 | uses: golangci/golangci-lint-action@v3 26 | with: 27 | # Require: The version of golangci-lint to use. 28 | # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. 29 | # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. 30 | version: latest 31 | 32 | # Optional: working directory, useful for monorepos 33 | # working-directory: somedir 34 | 35 | # Optional: golangci-lint command line arguments. 36 | # 37 | # Note: By default, the `.golangci.yml` file should be at the root of the repository. 38 | # The location of the configuration file can be changed by using `--config=` 39 | # args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0 40 | args: --out-format=colored-line-number 41 | 42 | # Optional: show only new issues if it's a pull request. The default value is `false`. 43 | # only-new-issues: true 44 | 45 | # Optional: if set to true, then all caching functionality will be completely disabled, 46 | # takes precedence over all other caching options. 47 | # skip-cache: true 48 | 49 | # Optional: if set to true, then the action won't cache or restore ~/go/pkg. 50 | # skip-pkg-cache: true 51 | 52 | # Optional: if set to true, then the action won't cache or restore ~/.cache/go-build. 53 | # skip-build-cache: true 54 | 55 | # Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'. 56 | # install-mode: "goinstall" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | .git/ -------------------------------------------------------------------------------- /ADVANCED.md: -------------------------------------------------------------------------------- 1 | # Advanced Use Cases 2 | The go-partial package offers advanced features to handle dynamic content rendering based on user interactions or server-side logic. Below are three advanced use cases: 3 | 4 | - **WithSelection**: Selecting partials based on a selection key. 5 | - **WithAction**: Executing server-side actions during request processing. 6 | - **WithTemplateAction**: Invoking actions from within templates. 7 | 8 | ## WithSelection 9 | ### Purpose 10 | WithSelection allows you to select and render one of several predefined partials based on a selection key, such as a header value or query parameter. This is useful for rendering different content based on user interaction, like tabbed interfaces. 11 | ### When to Use 12 | Use WithSelection when you have a static set of partials and need to render one of them based on a simple key provided by the client. 13 | ### How to Use 14 | #### Step 1: Define the Partials 15 | Create partials for each selectable content. 16 | ```go 17 | tab1Partial := partial.New("tab1.gohtml").ID("tab1") 18 | tab2Partial := partial.New("tab2.gohtml").ID("tab2") 19 | defaultPartial := partial.New("default.gohtml").ID("default") 20 | ``` 21 | 22 | #### Step 2: Create a Selection Map 23 | Map selection keys to their corresponding partials. 24 | ```go 25 | partialsMap := map[string]*partial.Partial{ 26 | "tab1": tab1Partial, 27 | "tab2": tab2Partial, 28 | "default": defaultPartial, 29 | } 30 | ``` 31 | 32 | #### Step 3: Set Up the Content Partial with Selection 33 | Use WithSelection to associate the selection map with your content partial. 34 | ```go 35 | contentPartial := partial.New("content.gohtml").ID("content").WithSelection("default", partialsMap) 36 | ``` 37 | 38 | #### Step 4: Update the Template 39 | In your content.gohtml template, use the {{selection}} function to render the selected partial. 40 | ```html 41 |
42 | {{selection}} 43 |
44 | ``` 45 | 46 | #### Step 5: Handle the Request 47 | In your handler, render the partial as usual. 48 | ```go 49 | func yourHandler(w http.ResponseWriter, r *http.Request) { 50 | ctx := r.Context() 51 | err := contentPartial.WriteWithRequest(ctx, w, r) 52 | if err != nil { 53 | http.Error(w, err.Error(), http.StatusInternalServerError) 54 | } 55 | } 56 | ``` 57 | 58 | #### Client-Side Usage 59 | Set the selection key via a header (e.g., X-Select) or another method. 60 | ```html 61 |
62 | 63 |
64 | ``` 65 | 66 | ## WithAction 67 | ### Purpose 68 | WithAction allows you to execute server-side logic during request processing, such as handling form submissions or performing business logic, and then render a partial based on the result. 69 | ### When to Use 70 | Use WithAction when you need to perform dynamic operations before rendering, such as processing form data, updating a database, or any logic that isn't just selecting a predefined partial. 71 | 72 | ### How to Use 73 | #### Step 1: Define the Partial with an Action 74 | Attach an action function to the partial using WithAction. 75 | ```go 76 | formPartial := partial.New("form.gohtml").ID("contactForm").WithAction(func(ctx context.Context, data *partial.Data) (*partial.Partial, error) { 77 | // Access form values 78 | name := data.Request.FormValue("name") 79 | email := data.Request.FormValue("email") 80 | 81 | // Perform validation and business logic 82 | if name == "" || email == "" { 83 | errorPartial := partial.New("error.gohtml").AddData("Message", "Name and email are required.") 84 | return errorPartial, nil 85 | } 86 | 87 | // Simulate saving to a database or performing an action 88 | // ... 89 | 90 | // Decide which partial to render next 91 | successPartial := partial.New("success.gohtml").AddData("Name", name) 92 | return successPartial, nil 93 | }) 94 | ``` 95 | #### Step 2: Handle the Request 96 | In your handler, render the partial with the request. 97 | ```go 98 | func submitHandler(w http.ResponseWriter, r *http.Request) { 99 | ctx := r.Context() 100 | err := formPartial.WriteWithRequest(ctx, w, r) 101 | if err != nil { 102 | http.Error(w, err.Error(), http.StatusInternalServerError) 103 | } 104 | } 105 | ``` 106 | #### Step 3: Client-Side Usage 107 | Submit the form with an X-Action header specifying the partial ID. 108 | ```html 109 |
110 | 111 | 112 | 113 |
114 | ``` 115 | #### Explanation 116 | 117 | - The form submission triggers the server to execute the action associated with contactForm. 118 | - The action processes the form data and returns a partial to render. 119 | - The server responds with the rendered partial (e.g., a success message). 120 | 121 | ## WithTemplateAction 122 | ### Purpose 123 | WithTemplateAction allows actions to be executed from within the template using a function like {{action "actionName"}}. This provides flexibility to execute actions conditionally or at specific points in your template. 124 | ### When to Use 125 | Use WithTemplateAction when you need to invoke actions directly from the template, possibly under certain conditions, or when you have multiple actions within a single template. 126 | ### How to Use 127 | 128 | #### Step 1: Define the Partial with Template Actions 129 | Attach template actions to your partial. 130 | ```go 131 | myPartial := partial.New("mytemplate.gohtml").ID("myPartial").WithTemplateAction("loadDynamicContent", func(ctx context.Context, data *partial.Data) (*partial.Partial, error) { 132 | // Load dynamic content 133 | dynamicPartial := partial.New("dynamic.gohtml") 134 | // Add data or perform operations 135 | return dynamicPartial, nil 136 | }) 137 | ``` 138 | #### Step 2: Update the Template 139 | In your mytemplate.gohtml template, invoke the action using the action function. 140 | ```html 141 |
142 | 143 | {{action "loadDynamicContent"}} 144 | 145 |
146 | ``` 147 | #### Step 3: Handle the Request 148 | Render the partial as usual. 149 | ```go 150 | func yourHandler(w http.ResponseWriter, r *http.Request) { 151 | ctx := r.Context() 152 | err := myPartial.WriteWithRequest(ctx, w, r) 153 | if err != nil { 154 | http.Error(w, err.Error(), http.StatusInternalServerError) 155 | } 156 | } 157 | ``` 158 | #### Use Cases 159 | 160 | - Conditional Execution 161 | ```gotemplate 162 | {{if .Data.ShowSpecialContent}} 163 | {{action "loadSpecialContent"}} 164 | {{end}} 165 | ``` 166 | - Lazy Loading 167 | ```gotemplate 168 |
169 | {{action "loadHeavyContent"}} 170 |
171 | ``` 172 | #### Explanation 173 | 174 | - Actions are only executed when the {{action "actionName"}} function is called in the template. 175 | - This allows for conditional or multiple action executions within the same template. 176 | - The server-side action returns a partial to render, which is then included at that point in the template. 177 | 178 | ## Choosing the Right Approach 179 | - Use `WithSelection` when you have a set of predefined partials and want to select one based on a simple key. 180 | - Use `WithAction` when you need to perform server-side logic during request processing and render a partial based on the result. 181 | - Use `WithTemplateAction` when you want to invoke actions directly from within the template, especially for conditional execution or multiple actions. 182 | 183 | ## Notes 184 | 185 | - **Separation of Concerns**: While WithTemplateAction provides flexibility, be cautious not to overload templates with business logic. Keep templates focused on presentation as much as possible. 186 | - **Error Handling**: Ensure that your actions handle errors gracefully and that your templates can render appropriately even if an action fails. 187 | - **Thread Safety**: If your application is concurrent, ensure that shared data is properly synchronized. -------------------------------------------------------------------------------- /INTEGRATIONS.md: -------------------------------------------------------------------------------- 1 | # Supported Integrations (connectors) 2 | 3 | `go-partial` currently supports the following connectors: 4 | 5 | - HTMX 6 | - Turbo 7 | - Unpoly 8 | - Alpine.js 9 | - Stimulus 10 | - Partial (Custom Connector) 11 | 12 | ## HTMX 13 | 14 | ### Description: 15 | [HTMX](https://htmx.org/) allows you to use AJAX, WebSockets, and Server-Sent Events directly in HTML using attributes. 16 | 17 | ### Server-Side Setup: 18 | ```go 19 | import ( 20 | "github.com/donseba/go-partial" 21 | "github.com/donseba/go-partial/connector" 22 | ) 23 | 24 | // Create a new partial 25 | contentPartial := partial.New("templates/content.gohtml").ID("content") 26 | 27 | // Set the HTMX connector 28 | contentPartial.SetConnector(connector.NewHTMX(&connector.Config{ 29 | UseURLQuery: true, // Enable fallback to URL query parameters 30 | })) 31 | 32 | // Optionally add actions or selections 33 | contentPartial.WithAction(func(ctx context.Context, p *partial.Partial, data *partial.Data) (*partial.Partial, error) { 34 | // Action logic here 35 | return p, nil 36 | }) 37 | 38 | // Handler function 39 | func contentHandler(w http.ResponseWriter, r *http.Request) { 40 | ctx := r.Context() 41 | err := contentPartial.WriteWithRequest(ctx, w, r) 42 | if err != nil { 43 | http.Error(w, err.Error(), http.StatusInternalServerError) 44 | } 45 | } 46 | ``` 47 | 48 | ### Client-Side Setup: 49 | ```html 50 | 51 | 52 | 53 | 54 | 55 |
56 | 57 |
58 | ``` 59 | 60 | ### alternative: 61 | ```html 62 | 63 | 64 | ``` 65 | 66 | ### alternative 2: 67 | ```html 68 | 69 | 70 | ``` 71 | 72 | ## Turbo 73 | ### Description: 74 | [Turbo](https://turbo.hotwired.dev/) speeds up web applications by reducing the amount of custom JavaScript needed to provide rich, modern user experiences. 75 | 76 | ### Server-Side Setup: 77 | ```go 78 | import ( 79 | "github.com/donseba/go-partial" 80 | "github.com/donseba/go-partial/connector" 81 | ) 82 | 83 | // Create a new partial 84 | contentPartial := partial.New("templates/content.gohtml").ID("content") 85 | 86 | // Set the Turbo connector 87 | contentPartial.SetConnector(connector.NewTurbo(&connector.Config{ 88 | UseURLQuery: true, 89 | })) 90 | 91 | // Handler function 92 | func contentHandler(w http.ResponseWriter, r *http.Request) { 93 | ctx := r.Context() 94 | err := contentPartial.WriteWithRequest(ctx, w, r) 95 | if err != nil { 96 | http.Error(w, err.Error(), http.StatusInternalServerError) 97 | } 98 | } 99 | ``` 100 | 101 | ### Client-Side Setup: 102 | ```html 103 | 104 | 105 | 106 | 107 | 108 | Tab 1 109 | Tab 2 110 | ``` 111 | 112 | ## Unpoly 113 | ### Description: 114 | [Unpoly](https://unpoly.com/) enables fast and flexible server-side rendering with minimal custom JavaScript. 115 | 116 | ### Server-Side Setup: 117 | ```go 118 | import ( 119 | "github.com/donseba/go-partial" 120 | "github.com/donseba/go-partial/connector" 121 | ) 122 | 123 | // Create a new partial 124 | contentPartial := partial.New("templates/content.gohtml").ID("content") 125 | 126 | // Set the Unpoly connector 127 | contentPartial.SetConnector(connector.NewUnpoly(&connector.Config{ 128 | UseURLQuery: true, 129 | })) 130 | 131 | // Handler function 132 | func contentHandler(w http.ResponseWriter, r *http.Request) { 133 | ctx := r.Context() 134 | err := contentPartial.WriteWithRequest(ctx, w, r) 135 | if err != nil { 136 | http.Error(w, err.Error(), http.StatusInternalServerError) 137 | } 138 | } 139 | ``` 140 | 141 | ### Client-Side Setup: 142 | ```html 143 | 144 | Tab 1 145 | Tab 2 146 | 147 | 148 |
149 | 150 |
151 | ``` 152 | 153 | ### Alternative: 154 | ```html 155 | Tab 1 156 | Tab 2 157 | ``` 158 | 159 | ## Alpine.js 160 | ### Description: 161 | [Alpine.js](https://alpinejs.dev/) offers a minimal and declarative way to render reactive components in the browser. 162 | 163 | ### Server-Side Setup: 164 | ```go 165 | import ( 166 | "github.com/donseba/go-partial" 167 | "github.com/donseba/go-partial/connector" 168 | ) 169 | 170 | // Create a new partial 171 | contentPartial := partial.New("templates/content.gohtml").ID("content") 172 | 173 | // Set the Alpine.js connector 174 | contentPartial.SetConnector(connector.NewAlpine(&connector.Config{ 175 | UseURLQuery: true, 176 | })) 177 | 178 | // Handler function 179 | func contentHandler(w http.ResponseWriter, r *http.Request) { 180 | ctx := r.Context() 181 | err := contentPartial.WriteWithRequest(ctx, w, r) 182 | if err != nil { 183 | http.Error(w, err.Error(), http.StatusInternalServerError) 184 | } 185 | } 186 | ``` 187 | 188 | ### Client-Side Setup: 189 | ```html 190 |
191 | 192 |
193 | 194 |
195 | 196 |
197 | 198 |
199 | 200 | 201 |
202 | 203 |
204 |
205 | ``` 206 | 207 | ## Alpine Ajax 208 | ### Description: 209 | [Alpine Ajax](https://alpine-ajax.js.org) is an Alpine.js plugin that enables your HTML elements to request remote content from your server. 210 | ### Server-Side Setup: 211 | ```go 212 | import ( 213 | "github.com/donseba/go-partial" 214 | "github.com/donseba/go-partial/connector" 215 | ) 216 | 217 | // Create a new partial 218 | contentPartial := partial.New("templates/content.gohtml").ID("content") 219 | 220 | // Set the Alpine-AJAX connector 221 | contentPartial.SetConnector(connector.NewAlpineAjax(&connector.Config{ 222 | UseURLQuery: true, // Enable fallback to URL query parameters 223 | })) 224 | 225 | // Handler function 226 | func contentHandler(w http.ResponseWriter, r *http.Request) { 227 | ctx := r.Context() 228 | err := contentPartial.WriteWithRequest(ctx, w, r) 229 | if err != nil { 230 | http.Error(w, err.Error(), http.StatusInternalServerError) 231 | } 232 | } 233 | ``` 234 | 235 | ### Client-Side Setup: 236 | ```html 237 | 238 | 239 | 240 | 241 | 242 |
243 | 244 | 245 | 246 | 247 | 248 | 249 |
250 | 251 |
252 |
253 | 254 | ``` 255 | 256 | ## Stimulus 257 | ### Description: 258 | [Stimulus](https://stimulus.hotwired.dev/) is a JavaScript framework that enhances static or server-rendered HTML with just enough behavior. 259 | 260 | ### Server-Side Setup: 261 | ```go 262 | import ( 263 | "github.com/donseba/go-partial" 264 | "github.com/donseba/go-partial/connector" 265 | ) 266 | 267 | // Create a new partial 268 | contentPartial := partial.New("templates/content.gohtml").ID("content") 269 | 270 | // Set the Stimulus connector 271 | contentPartial.SetConnector(connector.NewStimulus(&connector.Config{ 272 | UseURLQuery: true, 273 | })) 274 | 275 | // Handler function 276 | func contentHandler(w http.ResponseWriter, r *http.Request) { 277 | ctx := r.Context() 278 | err := contentPartial.WriteWithRequest(ctx, w, r) 279 | if err != nil { 280 | http.Error(w, err.Error(), http.StatusInternalServerError) 281 | } 282 | } 283 | ``` 284 | 285 | ### Client-Side Setup: 286 | ```html 287 |
288 | 289 | 290 | 291 | 292 | 293 |
294 | 295 |
296 |
297 | 298 | 318 | ``` 319 | 320 | ## Partial (Custom Connector) 321 | ### Description: 322 | The Partial connector is a simple, custom connector provided by go-partial. It can be used when you don't rely on any specific front-end library. 323 | 324 | ### Server-Side Setup: 325 | ```go 326 | import ( 327 | "github.com/donseba/go-partial" 328 | "github.com/donseba/go-partial/connector" 329 | ) 330 | 331 | // Create a new partial 332 | contentPartial := partial.New("templates/content.gohtml").ID("content") 333 | 334 | // Set the custom Partial connector 335 | contentPartial.SetConnector(connector.NewPartial(&connector.Config{ 336 | UseURLQuery: true, 337 | })) 338 | 339 | // Handler function 340 | func contentHandler(w http.ResponseWriter, r *http.Request) { 341 | ctx := r.Context() 342 | err := contentPartial.WriteWithRequest(ctx, w, r) 343 | if err != nil { 344 | http.Error(w, err.Error(), http.StatusInternalServerError) 345 | } 346 | } 347 | 348 | ``` 349 | ### Client-Side Usage: 350 | ```html 351 | 352 | 353 | 354 | 355 |
356 | 357 |
358 | ``` 359 | 360 | ## Vue.js 361 | ### Description: 362 | [Vue.js](https://vuejs.org/) is a progressive JavaScript framework for building user interfaces. 363 | 364 | ### Note: 365 | Integrating go-partial with Vue.js for partial HTML updates is possible but comes with limitations. For small sections of the page or simple content updates, it can work. For larger applications, consider whether server-rendered partials align with your architecture. 366 | 367 | ### Server-Side Setup: 368 | ```go 369 | import ( 370 | "github.com/donseba/go-partial" 371 | "github.com/donseba/go-partial/connector" 372 | ) 373 | 374 | // Create a new partial 375 | contentPartial := partial.New("templates/content.gohtml").ID("content") 376 | 377 | // Set the Vue connector 378 | contentPartial.SetConnector(connector.NewVue(&connector.Config{ 379 | UseURLQuery: true, 380 | })) 381 | 382 | // Handler function 383 | func contentHandler(w http.ResponseWriter, r *http.Request) { 384 | ctx := r.Context() 385 | err := contentPartial.WriteWithRequest(ctx, w, r) 386 | if err != nil { 387 | http.Error(w, err.Error(), http.StatusInternalServerError) 388 | } 389 | } 390 | ``` 391 | 392 | ### Client-Side Setup: 393 | ```html 394 | 404 | 405 | 428 | ``` 429 | 430 | ### using axios: 431 | ```javascript 432 | methods: { 433 | loadContent(select) { 434 | axios.get('/content', { 435 | headers: { 436 | 'X-Vue-Target': 'content', 437 | 'X-Vue-Select': select 438 | } 439 | }) 440 | .then(response => { 441 | this.content = response.data; 442 | }); 443 | } 444 | } 445 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sebastiano Bellinzis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Go Partial - Partial Page Rendering for Go 2 | 3 | This package provides a flexible and efficient way to manage and render partial templates in Go (Golang). It allows you to create reusable, hierarchical templates with support for layouts, global data, caching, and more. 4 | ## Features 5 | 6 | - **Partial Templates**: Define and render partial templates with their own data and functions. 7 | - **Layouts**: Use layouts to wrap content and share data across multiple partials. 8 | - **Global Data**: Set global data accessible to all partials. 9 | - **Template Caching**: Enable caching of parsed templates for improved performance. 10 | - **Out-of-Band Rendering**: Support for rendering out-of-band (OOB) partials. 11 | - **File System Support**: Use custom fs.FS implementations for template file access. 12 | - **Thread-Safe**: Designed for concurrent use in web applications. 13 | 14 | ## Installation 15 | To install the package, run: 16 | ```bash 17 | go get github.com/donseba/go-partial 18 | ``` 19 | 20 | ## Advanced use cases 21 | Advanced usecases are documented in the [ADVANCED.md](ADVANCED.md) file 22 | 23 | ## Integrations 24 | Several integrations are available, detailed information can be found in the [INTEGRATIONS.md](INTEGRATIONS.md) file 25 | - htmx 26 | - Turbo 27 | - Stimulus 28 | - Unpoly 29 | - Alpine.js / Alpine Ajax (not great) 30 | - Vue.js (not great) 31 | - Standalone 32 | 33 | ## Basic Usage 34 | Here's a simple example of how to use the package to render a template. 35 | 36 | ### 1. Create a Service 37 | The `Service` holds global configurations and data. 38 | 39 | ```go 40 | cfg := &partial.Config{ 41 | PartialHeader: "X-Target", // Optional: Header to determine which partial to render 42 | UseCache: true, // Enable template caching 43 | FuncMap: template.FuncMap{}, // Global template functions 44 | Logger: myLogger, // Implement the Logger interface or use nil 45 | } 46 | 47 | service := partial.NewService(cfg) 48 | service.SetData(map[string]any{ 49 | "AppName": "My Application", 50 | }) 51 | 52 | ``` 53 | 54 | ## 2. Create a Layout 55 | The `Layout` manages the overall structure of your templates. 56 | ```go 57 | layout := service.NewLayout() 58 | layout.SetData(map[string]any{ 59 | "PageTitle": "Home Page", 60 | }) 61 | ``` 62 | 63 | ### 3. Define Partials 64 | Create `Partial` instances for the content and any other components. 65 | 66 | ```go 67 | func handler(w http.ResponseWriter, r *http.Request) { 68 | // Create the main content partial 69 | content := partial.NewID("content", "templates/content.html") 70 | content.SetData(map[string]any{ 71 | "Message": "Welcome to our website!", 72 | }) 73 | 74 | // Optionally, create a wrapper partial (layout) 75 | wrapper := partial.NewID("wrapper", "templates/layout.html") 76 | 77 | layout.Set(content) 78 | layout.Wrap(wrapper) 79 | 80 | output, err := layout.RenderWithRequest(r.Context(), r) 81 | if err != nil { 82 | http.Error(w, "An error occurred while rendering the page.", http.StatusInternalServerError) 83 | return 84 | } 85 | w.Write([]byte(output)) 86 | } 87 | ``` 88 | 89 | ## Template Files 90 | templates/layout.html 91 | ```html 92 | 93 | 94 | 95 | {{.Layout.PageTitle}} - {{.Service.AppName}} 96 | 97 | 98 | {{ child "content" }} 99 | 100 | 101 | ``` 102 | templates/content.html 103 | ```html 104 |

{{.Data.Message}}

105 | ``` 106 | 107 | Note: In the layout template, we use {{ child "content" }} to render the content partial on demand. 108 | 109 | 110 | ### Using Global and Layout Data 111 | - **Global Data (ServiceData)**: Set on the Service, accessible via {{.Service}} in templates. 112 | - **Layout Data (LayoutData)**: Set on the Layout, accessible via {{.Layout}} in templates. 113 | - **Partial Data (Data)**: Set on individual Partial instances, accessible via {{.Data}} in templates. 114 | 115 | ### Accessing Data in Templates 116 | 117 | You can access data in your templates using dot notation: 118 | 119 | - **Partial Data**: `{{ .Data.Key }}` 120 | - **Layout Data**: `{{ .Layout.Key }}` 121 | - **Global Data**: `{{ .Service.Key }}` 122 | 123 | 124 | ### Wrapping Partials 125 | You can wrap a partial with another partial, such as wrapping content with a layout: 126 | 127 | ```go 128 | // Create the wrapper partial (e.g., layout) 129 | layout := partial.New("templates/layout.html").ID("layout") 130 | 131 | // Wrap the content partial with the layout 132 | content.Wrap(layout) 133 | ``` 134 | 135 | ## Rendering Child Partials on Demand 136 | Use the child function to render child partials within your templates. 137 | 138 | ### Syntax 139 | ```html 140 | {{ child "partial_id" }} 141 | ``` 142 | You can also pass data to the child partial using key-value pairs: 143 | 144 | ```html 145 | {{ child "sidebar" "UserName" .Data.UserName "Notifications" .Data.Notifications }} 146 | ``` 147 | Child Partial (sidebar): 148 | ```html 149 |
150 |

User: {{ .Data.UserName }}

151 |

Notifications: {{ .Data.Notifications }}

152 |
153 | ``` 154 | 155 | ## Using Out-of-Band (OOB) Partials 156 | Out-of-Band partials allow you to update parts of the page without reloading: 157 | 158 | ### Defining an OOB Partial 159 | ```go 160 | // Create the OOB partial 161 | footer := partial.New("templates/footer.html").ID("footer") 162 | footer.SetData(map[string]any{ 163 | "Text": "This is the footer", 164 | }) 165 | 166 | // Add the OOB partial 167 | p.WithOOB(footer) 168 | ``` 169 | 170 | ### Using OOB Partials in Templates 171 | In your templates, you can use the swapOOB function to conditionally render OOB attributes. 172 | 173 | templates/footer.html 174 | ```html 175 | 176 | ``` 177 | 178 | ## Wrapping Partials 179 | You can wrap a partial with another partial, such as wrapping content with a layout. 180 | 181 | ```go 182 | // Create the wrapper partial (e.g., layout) 183 | layoutPartial := partial.New("templates/layout.html").ID("layout") 184 | 185 | // Wrap the content partial with the layout 186 | content.Wrap(layoutPartial) 187 | 188 | ``` 189 | 190 | ## Template Functions 191 | You can add custom functions to be used within your templates: 192 | 193 | ```go 194 | import "strings" 195 | 196 | // Define custom functions 197 | funcs := template.FuncMap{ 198 | "upper": strings.ToUpper, 199 | } 200 | 201 | // Set the functions for the partial 202 | p.SetFuncs(funcs) 203 | ``` 204 | 205 | ### Usage in Template: 206 | ```html 207 | {{ upper .Data.Message }} 208 | ``` 209 | 210 | ### Using a Custom File System 211 | If your templates are stored in a custom file system, you can set it using WithFS: 212 | 213 | ```go 214 | import ( 215 | "embed" 216 | ) 217 | 218 | //go:embed templates/* 219 | var content embed.FS 220 | 221 | p.WithFS(content) 222 | ``` 223 | 224 | If you do not use a custom file system, the package will use the default file system and look for templates relative to the current working directory. 225 | 226 | ## Rendering Tables and Dynamic Content 227 | You can render dynamic content like tables by rendering child partials within loops. 228 | 229 | Example: Rendering a Table with Dynamic Rows 230 | 231 | templates/table.html 232 | ```html 233 | 234 | {{ range $i := .Data.Rows }} 235 | {{ child "row" "RowNumber" $i }} 236 | {{ end }} 237 |
238 | ``` 239 | 240 | templates/row.html 241 | ```html 242 | 243 | {{ .Data.RowNumber }} 244 | 245 | ``` 246 | 247 | Go Code: 248 | ```go 249 | // Create the row partial 250 | rowPartial := partial.New("templates/row.html").ID("row") 251 | 252 | // Create the table partial and set data 253 | tablePartial := partial.New("templates/table.html").ID("table") 254 | tablePartial.SetData(map[string]any{ 255 | "Rows": []int{1, 2, 3, 4, 5}, // Generate 5 rows 256 | }) 257 | tablePartial.With(rowPartial) 258 | 259 | // Render the table partial 260 | out, err := layout.Set(tablePartial).RenderWithRequest(r.Context(), r) 261 | ``` 262 | 263 | ## Template Data 264 | In your templates, you can access the following data: 265 | 266 | - **{{.Ctx}}**: The context of the request. 267 | - **{{.URL}}**: The URL of the request. 268 | - **{{.Data}}**: Data specific to this partial. 269 | - **{{.Service}}**: Global data available to all partials. 270 | - **{{.Layout}}**: Data specific to the layout. 271 | 272 | ## Concurrency and Template Caching 273 | The package includes concurrency safety measures for template caching: 274 | 275 | - Templates are cached using a sync.Map. 276 | - Mutexes are used to prevent race conditions during template parsing. 277 | - Set UseTemplateCache to true to enable template caching. 278 | 279 | ```go 280 | cfg := &partial.Config{ 281 | UseCache: true, 282 | } 283 | ``` 284 | 285 | ## Handling Partial Rendering via HTTP Headers 286 | You can render specific partials based on the X-Target header (or your custom header). 287 | 288 | Example: 289 | ```go 290 | func handler(w http.ResponseWriter, r *http.Request) { 291 | output, err := layout.RenderWithRequest(r.Context(), r) 292 | if err != nil { 293 | http.Error(w, "An error occurred while rendering the page.", http.StatusInternalServerError) 294 | return 295 | } 296 | w.Write([]byte(output)) 297 | } 298 | ``` 299 | 300 | To request a specific partial: 301 | ```bash 302 | curl -H "X-Target: sidebar" http://localhost:8080 303 | ``` 304 | 305 | ## Useless benchmark results 306 | 307 | with caching enabled 308 | ```bash 309 | goos: darwin 310 | goarch: arm64 311 | pkg: github.com/donseba/go-partial 312 | cpu: Apple M2 Pro 313 | BenchmarkRenderWithRequest 314 | BenchmarkRenderWithRequest-12 526102 2254 ns/op 315 | PASS 316 | ``` 317 | 318 | with caching disabled 319 | ```bash 320 | goos: darwin 321 | goarch: arm64 322 | pkg: github.com/donseba/go-partial 323 | cpu: Apple M2 Pro 324 | BenchmarkRenderWithRequest 325 | BenchmarkRenderWithRequest-12 57529 19891 ns/op 326 | PASS 327 | ``` 328 | 329 | which would mean that caching is rougly 9-10 times faster than without caching 330 | 331 | 332 | ## Contributing 333 | 334 | Contributions are welcome! Please open an issue or submit a pull request with your improvements. 335 | 336 | ## License 337 | 338 | This project is licensed under the [MIT License](LICENSE). 339 | ``` 340 | -------------------------------------------------------------------------------- /connector/alpine-ajax.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type AlpineAjax struct { 6 | base 7 | } 8 | 9 | func NewAlpineAjax(c *Config) Connector { 10 | return &AlpineAjax{ 11 | base: base{ 12 | config: c, 13 | targetHeader: "X-Alpine-Target", 14 | selectHeader: "X-Alpine-Select", 15 | actionHeader: "X-Alpine-Action", 16 | }, 17 | } 18 | } 19 | 20 | func (a *AlpineAjax) RenderPartial(r *http.Request) bool { 21 | return r.Header.Get(a.targetHeader) != "" 22 | } 23 | -------------------------------------------------------------------------------- /connector/alpine.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type Alpine struct { 6 | base 7 | } 8 | 9 | func NewAlpine(c *Config) Connector { 10 | return &Alpine{ 11 | base: base{ 12 | config: c, 13 | targetHeader: "X-Alpine-Target", 14 | selectHeader: "X-Alpine-Select", 15 | actionHeader: "X-Alpine-Action", 16 | }, 17 | } 18 | } 19 | 20 | func (a *Alpine) RenderPartial(r *http.Request) bool { 21 | return r.Header.Get(a.targetHeader) != "" 22 | } 23 | -------------------------------------------------------------------------------- /connector/connector.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type ( 6 | Connector interface { 7 | RenderPartial(r *http.Request) bool 8 | GetTargetValue(r *http.Request) string 9 | GetSelectValue(r *http.Request) string 10 | GetActionValue(r *http.Request) string 11 | 12 | GetTargetHeader() string 13 | GetSelectHeader() string 14 | GetActionHeader() string 15 | } 16 | 17 | Config struct { 18 | UseURLQuery bool 19 | } 20 | 21 | base struct { 22 | config *Config 23 | targetHeader string 24 | selectHeader string 25 | actionHeader string 26 | } 27 | ) 28 | 29 | func (x *base) RenderPartial(r *http.Request) bool { 30 | return r.Header.Get(x.targetHeader) != "" 31 | } 32 | 33 | func (x *base) GetTargetHeader() string { 34 | return x.targetHeader 35 | } 36 | 37 | func (x *base) GetSelectHeader() string { 38 | return x.selectHeader 39 | } 40 | 41 | func (x *base) GetActionHeader() string { 42 | return x.actionHeader 43 | } 44 | 45 | func (x *base) GetTargetValue(r *http.Request) string { 46 | if targetValue := r.Header.Get(x.targetHeader); targetValue != "" { 47 | return targetValue 48 | } 49 | 50 | if x.config.useURLQuery() { 51 | return r.URL.Query().Get("target") 52 | } 53 | 54 | return "" 55 | } 56 | 57 | func (x *base) GetSelectValue(r *http.Request) string { 58 | if selectValue := r.Header.Get(x.selectHeader); selectValue != "" { 59 | return selectValue 60 | } 61 | 62 | if x.config.useURLQuery() { 63 | return r.URL.Query().Get("select") 64 | } 65 | 66 | return "" 67 | } 68 | 69 | func (x *base) GetActionValue(r *http.Request) string { 70 | if actionValue := r.Header.Get(x.actionHeader); actionValue != "" { 71 | return actionValue 72 | } 73 | 74 | if x.config.useURLQuery() { 75 | return r.URL.Query().Get("action") 76 | } 77 | 78 | return "" 79 | } 80 | 81 | func (c *Config) useURLQuery() bool { 82 | if c == nil { 83 | return false 84 | } 85 | 86 | return c.UseURLQuery 87 | } 88 | -------------------------------------------------------------------------------- /connector/htmx.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | type HTMX struct { 8 | base 9 | 10 | requestHeader string 11 | boostedHeader string 12 | historyRestoreRequestHeader string 13 | } 14 | 15 | func NewHTMX(c *Config) Connector { 16 | return &HTMX{ 17 | base: base{ 18 | config: c, 19 | targetHeader: "HX-Target", 20 | selectHeader: "X-Select", 21 | actionHeader: "X-Action", 22 | }, 23 | requestHeader: "HX-Request", 24 | boostedHeader: "HX-Boosted", 25 | historyRestoreRequestHeader: "HX-History-Restore-Request", 26 | } 27 | } 28 | 29 | func (h *HTMX) RenderPartial(r *http.Request) bool { 30 | hxRequest := r.Header.Get(h.requestHeader) 31 | hxBoosted := r.Header.Get(h.boostedHeader) 32 | hxHistoryRestoreRequest := r.Header.Get(h.historyRestoreRequestHeader) 33 | 34 | return (hxRequest == "true" || hxBoosted == "true") && hxHistoryRestoreRequest != "true" 35 | } 36 | -------------------------------------------------------------------------------- /connector/partial.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | type Partial struct { 4 | base 5 | } 6 | 7 | func NewPartial(c *Config) Connector { 8 | return &Partial{ 9 | base: base{ 10 | config: c, 11 | targetHeader: "X-Target", 12 | selectHeader: "X-Select", 13 | actionHeader: "X-Action", 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /connector/stimulus.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type Stimulus struct { 6 | base 7 | } 8 | 9 | func NewStimulus(c *Config) Connector { 10 | return &Stimulus{ 11 | base: base{ 12 | config: c, 13 | targetHeader: "X-Stimulus-Target", 14 | selectHeader: "X-Stimulus-Select", 15 | actionHeader: "X-Stimulus-Action", 16 | }, 17 | } 18 | } 19 | 20 | func (s *Stimulus) RenderPartial(r *http.Request) bool { 21 | return r.Header.Get(s.targetHeader) != "" 22 | } 23 | -------------------------------------------------------------------------------- /connector/turbo.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | type Turbo struct { 4 | base 5 | } 6 | 7 | func NewTurbo(c *Config) Connector { 8 | return &Turbo{ 9 | base: base{ 10 | config: c, 11 | targetHeader: "Turbo-Frame", 12 | selectHeader: "Turbo-Select", 13 | actionHeader: "Turbo-Action", 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /connector/unpoly.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type Unpoly struct { 6 | base 7 | } 8 | 9 | func NewUnpoly(c *Config) Connector { 10 | return &Unpoly{ 11 | base: base{ 12 | config: c, 13 | targetHeader: "X-Up-Target", 14 | selectHeader: "X-Up-Select", 15 | actionHeader: "X-Up-Action", 16 | }, 17 | } 18 | } 19 | 20 | func (u *Unpoly) RenderPartial(r *http.Request) bool { 21 | return r.Header.Get(u.targetHeader) != "" 22 | } 23 | -------------------------------------------------------------------------------- /connector/vuejs.go: -------------------------------------------------------------------------------- 1 | package connector 2 | 3 | import "net/http" 4 | 5 | type Vue struct { 6 | base 7 | } 8 | 9 | func NewVue(c *Config) Connector { 10 | return &Vue{ 11 | base: base{ 12 | config: c, 13 | targetHeader: "X-Vue-Target", 14 | selectHeader: "X-Vue-Select", 15 | actionHeader: "X-Vue-Action", 16 | }, 17 | } 18 | } 19 | 20 | func (v *Vue) RenderPartial(r *http.Request) bool { 21 | return r.Header.Get(v.targetHeader) != "" 22 | } 23 | -------------------------------------------------------------------------------- /examples/form/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/donseba/go-partial" 8 | "log/slog" 9 | "net/http" 10 | "path/filepath" 11 | ) 12 | 13 | type ( 14 | App struct { 15 | PartialService *partial.Service 16 | } 17 | 18 | FormData struct { 19 | Username string `json:"username"` 20 | Password string `json:"password"` 21 | HiddenField string `json:"hiddenField"` 22 | } 23 | ) 24 | 25 | func main() { 26 | logger := slog.Default() 27 | 28 | app := &App{ 29 | PartialService: partial.NewService(&partial.Config{ 30 | Logger: logger, 31 | }), 32 | } 33 | 34 | mux := http.NewServeMux() 35 | 36 | mux.Handle("GET /js/", http.StripPrefix("/js/", http.FileServer(http.Dir("../../js")))) 37 | mux.HandleFunc("GET /", app.home) 38 | mux.HandleFunc("POST /", app.home) 39 | 40 | server := &http.Server{ 41 | Addr: ":8080", 42 | Handler: mux, 43 | } 44 | 45 | logger.Info("starting server on :8080") 46 | err := server.ListenAndServe() 47 | if err != nil { 48 | logger.Error("error starting server", "error", err) 49 | } 50 | } 51 | 52 | func (a *App) home(w http.ResponseWriter, r *http.Request) { 53 | layout := a.PartialService.NewLayout() 54 | footer := partial.NewID("footer", filepath.Join("templates", "footer.gohtml")) 55 | index := partial.NewID("index", filepath.Join("templates", "index.gohtml")).WithOOB(footer) 56 | content := partial.NewID("form", filepath.Join("templates", "form.gohtml")).WithAction(func(ctx context.Context, p *partial.Partial, data *partial.Data) (*partial.Partial, error) { 57 | switch p.GetRequestedAction() { 58 | case "submit": 59 | formData := &FormData{} 60 | err := json.NewDecoder(r.Body).Decode(formData) 61 | if err != nil { 62 | return nil, fmt.Errorf("error decoding form data: %w", err) 63 | } 64 | 65 | w.Header().Set("X-Event-Notify", `{"type": "success", "message": "Form submitted successfully"}`) 66 | p = p.Templates(filepath.Join("templates", "submitted.gohtml")).AddData("formData", formData) 67 | } 68 | 69 | return p, nil 70 | }) 71 | 72 | layout.Set(content).Wrap(index) 73 | 74 | err := layout.WriteWithRequest(r.Context(), w, r) 75 | if err != nil { 76 | http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /examples/form/templates/footer.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/form/templates/form.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 6 | 7 |
8 |
-------------------------------------------------------------------------------- /examples/form/templates/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Form Example 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{ child "form" }} 12 |
13 | 14 |
15 |
(rendered on load at : {{ formatDate now "15:04:05" }})
16 |
What the handler looks like:
17 |
func (a *App) home(w http.ResponseWriter, r *http.Request) {
18 | 	layout := a.PartialService.NewLayout()
19 | 	footer := partial.NewID("footer", filepath.Join("templates", "footer.gohtml"))
20 | 	index := partial.NewID("index", filepath.Join("templates", "index.gohtml")).WithOOB(footer)
21 | 	content := partial.NewID("form", filepath.Join("templates", "form.gohtml")).WithAction(func(ctx context.Context, p *partial.Partial, data *partial.Data) (*partial.Partial, error) {
22 | 		switch p.GetRequestedAction() {
23 | 		case "submit":
24 | 			formData := &FormData{}
25 | 			err := json.NewDecoder(r.Body).Decode(formData)
26 | 			if err != nil {
27 | 				return nil, fmt.Errorf("error decoding form data: %w", err)
28 | 			}
29 | 
30 | 			w.Header().Set("X-Event-Notify", `{"type": "success", "message": "Form submitted successfully"}`)
31 | 			p = p.Templates(filepath.Join("templates", "submitted.gohtml")).AddData("formData", formData)
32 | 		}
33 | 
34 | 		return p, nil
35 | 	})
36 | 
37 | 	layout.Set(content).Wrap(index)
38 | 
39 | 	err := layout.WriteWithRequest(r.Context(), w, r)
40 | 	if err != nil {
41 | 		http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError)
42 | 	}
43 | }
44 | 45 |
What the form looks like:
46 |
<div class="container mt-5">
47 |     <!-- Example Form with JSON Serialization -->
48 |     <form x-post="/submit" x-serialize="json" x-target="#form" x-action="submit">
49 |         <input type="text" name="username" />
50 |         <input type="password" name="password" />
51 |         <button type="submit">Submit</button>
52 |     </form>
53 | </div>
54 |
55 | 56 | 57 | 58 | {{ child "footer" }} 59 | 60 | 70 | 71 | -------------------------------------------------------------------------------- /examples/form/templates/submitted.gohtml: -------------------------------------------------------------------------------- 1 |
2 |
Form Submitted
3 | 4 |
Received
5 |
6 |         {{ debug .Data.formData }}
7 |     
8 |
-------------------------------------------------------------------------------- /examples/infinitescroll/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "path/filepath" 9 | "strconv" 10 | 11 | "github.com/donseba/go-partial" 12 | "github.com/donseba/go-partial/connector" 13 | ) 14 | 15 | type ( 16 | App struct { 17 | PartialService *partial.Service 18 | } 19 | ) 20 | 21 | func main() { 22 | logger := slog.Default() 23 | 24 | app := &App{ 25 | PartialService: partial.NewService(&partial.Config{ 26 | Logger: logger, 27 | Connector: connector.NewPartial(&connector.Config{ 28 | UseURLQuery: true, 29 | }), 30 | }), 31 | } 32 | 33 | mux := http.NewServeMux() 34 | 35 | mux.Handle("GET /js/", http.StripPrefix("/js/", http.FileServer(http.Dir("../../js")))) 36 | 37 | mux.HandleFunc("GET /", app.home) 38 | 39 | server := &http.Server{ 40 | Addr: ":8080", 41 | Handler: mux, 42 | } 43 | 44 | logger.Info("starting server on :8080") 45 | err := server.ListenAndServe() 46 | if err != nil { 47 | logger.Error("error starting server", "error", err) 48 | } 49 | } 50 | 51 | // super simple example of how to use the partial service to render a layout with a content partial 52 | func (a *App) home(w http.ResponseWriter, r *http.Request) { 53 | // layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance. 54 | layout := a.PartialService.NewLayout() 55 | footer := partial.NewID("footer", filepath.Join("templates", "footer.gohtml")) 56 | index := partial.NewID("index", filepath.Join("templates", "index.gohtml")).WithOOB(footer) 57 | 58 | content := partial.NewID("content", filepath.Join("templates", "content.gohtml")).WithAction(func(ctx context.Context, p *partial.Partial, data *partial.Data) (*partial.Partial, error) { 59 | switch p.GetRequestedAction() { 60 | case "infinite-scroll": 61 | return handleInfiniteScroll(p, data) 62 | default: 63 | return p, nil 64 | } 65 | }) 66 | 67 | // set the layout content and wrap it with the main template 68 | layout.Set(content).Wrap(index) 69 | 70 | err := layout.WriteWithRequest(r.Context(), w, r) 71 | if err != nil { 72 | http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError) 73 | } 74 | } 75 | 76 | type ( 77 | Row struct { 78 | ID int 79 | Name string 80 | Desc string 81 | } 82 | ) 83 | 84 | func handleInfiniteScroll(p *partial.Partial, data *partial.Data) (*partial.Partial, error) { 85 | startID := 0 86 | if p.GetRequest().URL.Query().Get("ID") != "" { 87 | startID, _ = strconv.Atoi(p.GetRequest().URL.Query().Get("ID")) 88 | } 89 | 90 | if startID >= 100 { 91 | p.SetResponseHeaders(map[string]string{ 92 | "X-Swap": "innerHTML", 93 | "X-Infinite-Scroll": "stop", 94 | }) 95 | p = partial.NewID("rickrolled", filepath.Join("templates", "rickrolled.gohtml")) 96 | } else { 97 | data.Data["Rows"] = generateNextRows(startID, 10) 98 | } 99 | 100 | return p, nil 101 | } 102 | 103 | func generateNextRows(lastID int, count int) []Row { 104 | var out []Row 105 | start := lastID + 1 106 | end := lastID + count 107 | 108 | for i := start; i <= end; i++ { 109 | out = append(out, Row{ 110 | ID: i, 111 | Name: fmt.Sprintf("Name %d", i), 112 | Desc: fmt.Sprintf("Description %d", i), 113 | }) 114 | } 115 | 116 | return out 117 | } 118 | -------------------------------------------------------------------------------- /examples/infinitescroll/templates/content.gohtml: -------------------------------------------------------------------------------- 1 | {{ with .Data.rickrolled }} 2 | {{ . }} 3 | {{ else }} 4 | {{ range $k, $v := .Data.Rows }} 5 |
6 |
7 | {{ $v.ID }} 8 |
9 |
10 | {{ $v.Name }} 11 |
12 |
13 | {{ $v.Desc }} 14 |
15 |
16 | {{ end}} 17 | {{ end }} 18 | 19 | -------------------------------------------------------------------------------- /examples/infinitescroll/templates/footer.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/infinitescroll/templates/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Infinite Scroll 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
(rendered on load at : {{ formatDate now "15:04:05" }})
14 |
What the handler looks like:
15 | 16 |
func (a *App) home(w http.ResponseWriter, r *http.Request) {
17 | 	// layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance.
18 | 	layout := a.PartialService.NewLayout()
19 | 	footer := partial.NewID("footer", filepath.Join("templates", "footer.gohtml"))
20 | 	index := partial.NewID("index", filepath.Join("templates", "index.gohtml")).WithOOB(footer)
21 | 
22 | 	content := partial.NewID("content", filepath.Join("templates", "content.gohtml")).WithAction(func(ctx context.Context, p *partial.Partial, data *partial.Data) (*partial.Partial, error) {
23 | 		switch p.GetRequestedAction() {
24 | 		case "infinite-scroll":
25 | 			return handleInfiniteScroll(p, data)
26 | 		default:
27 | 			return p, nil
28 | 		}
29 | 	})
30 | 
31 | 	// set the layout content and wrap it with the main template
32 | 	layout.Set(content).Wrap(index)
33 | 
34 | 	err := layout.WriteWithRequest(r.Context(), w, r)
35 | 	if err != nil {
36 | 		http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError)
37 | 	}
38 | }
39 |
40 | 41 | {{ child "footer" }} 42 | 43 |
44 | {{ child "content" }} 45 |
46 | 47 | 51 | 52 | -------------------------------------------------------------------------------- /examples/infinitescroll/templates/rickrolled.gohtml: -------------------------------------------------------------------------------- 1 |
2 |

Don’t scroll too far, there are consequences

3 |
That's enough scrolling for you today.
4 | 5 |
-------------------------------------------------------------------------------- /examples/tabs-htmx/content.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 14 | 15 |
16 | {{ selection }} 17 |
18 | 19 |
The handler:
20 | 21 |
func (a *App) home(w http.ResponseWriter, r *http.Request) {
22 | 	// the tabs for this page.
23 | 	selectMap := map[string]*partial.Partial{
24 | 		"tab1": partial.New("tab1.gohtml"),
25 | 		"tab2": partial.New("tab2.gohtml"),
26 | 		"tab3": partial.New("tab3.gohtml"),
27 | 	}
28 | 
29 | 	// layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance.
30 | 	layout := a.PartialService.NewLayout()
31 | 	footer := partial.NewID("footer", "footer.gohtml")
32 | 	index := partial.NewID("index", "index.gohtml").WithOOB(footer)
33 | 
34 | 	content := partial.NewID("content", "content.gohtml").WithSelectMap("tab1", selectMap)
35 | 
36 | 	// set the layout content and wrap it with the main template
37 | 	layout.Set(content).Wrap(index)
38 | 
39 | 	err := layout.WriteWithRequest(r.Context(), w, r)
40 | 	if err != nil {
41 | 		http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError)
42 | 	}
43 | }
44 |
-------------------------------------------------------------------------------- /examples/tabs-htmx/footer.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/tabs-htmx/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tab Example 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | {{ child "content" }} 13 |
14 | 15 | {{ child "footer" }} 16 | 17 | -------------------------------------------------------------------------------- /examples/tabs-htmx/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/donseba/go-partial" 9 | "github.com/donseba/go-partial/connector" 10 | ) 11 | 12 | type ( 13 | App struct { 14 | PartialService *partial.Service 15 | } 16 | ) 17 | 18 | func main() { 19 | logger := slog.Default() 20 | 21 | app := &App{ 22 | PartialService: partial.NewService(&partial.Config{ 23 | Logger: logger, 24 | Connector: connector.NewHTMX(nil), 25 | }), 26 | } 27 | 28 | mux := http.NewServeMux() 29 | 30 | mux.Handle("GET /js/", http.StripPrefix("/js/", http.FileServer(http.Dir("../../js")))) 31 | 32 | mux.HandleFunc("GET /", app.home) 33 | 34 | server := &http.Server{ 35 | Addr: ":8080", 36 | Handler: mux, 37 | } 38 | 39 | logger.Info("starting server on :8080") 40 | err := server.ListenAndServe() 41 | if err != nil { 42 | logger.Error("error starting server", "error", err) 43 | } 44 | } 45 | 46 | // super simple example of how to use the partial service to render a layout with a content partial 47 | func (a *App) home(w http.ResponseWriter, r *http.Request) { 48 | // the tabs for this page. 49 | selectMap := map[string]*partial.Partial{ 50 | "tab1": partial.New("tab1.gohtml"), 51 | "tab2": partial.New("tab2.gohtml"), 52 | "tab3": partial.New("tab3.gohtml"), 53 | } 54 | 55 | // layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance. 56 | layout := a.PartialService.NewLayout() 57 | footer := partial.NewID("footer", "footer.gohtml") 58 | index := partial.NewID("index", "index.gohtml").WithOOB(footer) 59 | 60 | content := partial.NewID("content", "content.gohtml").WithSelectMap("tab1", selectMap) 61 | 62 | // set the layout content and wrap it with the main template 63 | layout.Set(content).Wrap(index) 64 | 65 | err := layout.WriteWithRequest(r.Context(), w, r) 66 | if err != nil { 67 | http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /examples/tabs-htmx/tab1.gohtml: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque pulvinar massa pulvinar eros molestie pellentesque. Mauris facilisis libero leo, non cursus ex facilisis a. Donec tempor metus non ex efficitur, in vestibulum nunc dapibus. Etiam pretium tortor magna, eget tempus lacus varius et. Sed vestibulum velit sed odio facilisis dignissim. Fusce in dolor ac enim consequat cursus et id lorem. Donec convallis lorem dignissim tristique pellentesque. Etiam ultricies sed mauris vitae hendrerit. Maecenas accumsan ligula vel libero faucibus, in lacinia justo ullamcorper. Etiam pulvinar ex ac odio posuere bibendum. Pellentesque ipsum justo, finibus in egestas ac, dignissim varius neque. Fusce laoreet consequat diam, ut imperdiet libero laoreet quis. Aenean tincidunt a tellus vel posuere. Aenean vel elementum mauris. Pellentesque erat tortor, lobortis ac ullamcorper vitae, sagittis vel arcu. Morbi malesuada, justo ut dignissim mollis, diam nunc consequat enim, nec facilisis ex felis ac dui. -------------------------------------------------------------------------------- /examples/tabs-htmx/tab2.gohtml: -------------------------------------------------------------------------------- 1 | Suspendisse blandit, nisl ac porta auctor, mauris nisi elementum enim, non laoreet mauris justo eu sem. Cras eget urna id libero posuere luctus vitae ac lacus. Nunc varius iaculis leo, eu ultrices ligula aliquam non. Suspendisse lacinia magna enim, a ornare leo placerat in. Sed accumsan sapien ligula, sed maximus enim rutrum ut. Fusce leo purus, vestibulum nec dui accumsan, ullamcorper viverra risus. Duis fermentum orci augue, non sagittis orci tempor at. Praesent quis ipsum fermentum, consequat massa at, finibus eros. Praesent nec massa nisi. Proin sed feugiat eros. -------------------------------------------------------------------------------- /examples/tabs-htmx/tab3.gohtml: -------------------------------------------------------------------------------- 1 | Morbi elementum varius suscipit. Phasellus congue feugiat sem, vel sodales odio varius eu. Fusce non ex nisi. Aenean nisi dui, tincidunt nec est quis, mollis tempus libero. Fusce placerat pharetra diam, ac mollis turpis bibendum ac. Nullam pulvinar venenatis lacinia. Nam vel quam non ante dignissim bibendum id et ex. Suspendisse potenti. -------------------------------------------------------------------------------- /examples/tabs/content.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 14 | 15 |
16 | {{ selection }} 17 |
18 |
-------------------------------------------------------------------------------- /examples/tabs/footer.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/tabs/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tab Example 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{ child "content" }} 12 |
13 | 14 |
15 |
(rendered on load at : {{ formatDate now "15:04:05" }})
16 |
What the handler looks like:
17 | 18 |
func (a *App) home(w http.ResponseWriter, r *http.Request) {
19 | 	// the tabs for this page.
20 | 	selectMap := map[string]*partial.Partial{
21 | 		"tab1": partial.New("tab1.gohtml"),
22 | 		"tab2": partial.New("tab2.gohtml"),
23 | 		"tab3": partial.New("tab3.gohtml"),
24 | 	}
25 | 
26 | 	// layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance.
27 | 	layout := a.PartialService.NewLayout()
28 | 	footer := partial.NewID("footer", "footer.gohtml")
29 | 	index := partial.NewID("index", "index.gohtml").WithOOB(footer)
30 | 
31 | 	content := partial.NewID("content", "content.gohtml").WithSelectMap("tab1", selectMap)
32 | 
33 | 	// set the layout content and wrap it with the main template
34 | 	layout.Set(content).Wrap(index)
35 | 
36 | 	err := layout.WriteWithRequest(r.Context(), w, r)
37 | 	if err != nil {
38 | 		http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError)
39 | 	}
40 | }
41 |
42 | 43 | {{ child "footer" }} 44 | 45 | 53 | 54 | -------------------------------------------------------------------------------- /examples/tabs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "path/filepath" 8 | 9 | "github.com/donseba/go-partial" 10 | "github.com/donseba/go-partial/connector" 11 | ) 12 | 13 | type ( 14 | App struct { 15 | PartialService *partial.Service 16 | } 17 | ) 18 | 19 | func main() { 20 | logger := slog.Default() 21 | 22 | app := &App{ 23 | PartialService: partial.NewService(&partial.Config{ 24 | Logger: logger, 25 | Connector: connector.NewPartial(&connector.Config{ 26 | UseURLQuery: true, 27 | }), 28 | }), 29 | } 30 | 31 | mux := http.NewServeMux() 32 | 33 | mux.Handle("GET /js/", http.StripPrefix("/js/", http.FileServer(http.Dir("../../js")))) 34 | 35 | mux.HandleFunc("GET /", app.home) 36 | 37 | server := &http.Server{ 38 | Addr: ":8080", 39 | Handler: mux, 40 | } 41 | 42 | logger.Info("starting server on :8080") 43 | err := server.ListenAndServe() 44 | if err != nil { 45 | logger.Error("error starting server", "error", err) 46 | } 47 | } 48 | 49 | // super simple example of how to use the partial service to render a layout with a content partial 50 | func (a *App) home(w http.ResponseWriter, r *http.Request) { 51 | // the tabs for this page. 52 | selectMap := map[string]*partial.Partial{ 53 | "tab1": partial.New(filepath.Join("templates", "tab1.gohtml")), 54 | "tab2": partial.New(filepath.Join("templates", "tab2.gohtml")), 55 | "tab3": partial.New(filepath.Join("templates", "tab3.gohtml")), 56 | } 57 | 58 | // layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance. 59 | layout := a.PartialService.NewLayout() 60 | footer := partial.NewID("footer", filepath.Join("templates", "footer.gohtml")) 61 | index := partial.NewID("index", filepath.Join("templates", "index.gohtml")).WithOOB(footer) 62 | 63 | content := partial.NewID("content", filepath.Join("templates", "content.gohtml")).WithSelectMap("tab1", selectMap) 64 | 65 | // set the layout content and wrap it with the main template 66 | layout.Set(content).Wrap(index) 67 | 68 | err := layout.WriteWithRequest(r.Context(), w, r) 69 | if err != nil { 70 | http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /examples/tabs/tab1.gohtml: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque pulvinar massa pulvinar eros molestie pellentesque. Mauris facilisis libero leo, non cursus ex facilisis a. Donec tempor metus non ex efficitur, in vestibulum nunc dapibus. Etiam pretium tortor magna, eget tempus lacus varius et. Sed vestibulum velit sed odio facilisis dignissim. Fusce in dolor ac enim consequat cursus et id lorem. Donec convallis lorem dignissim tristique pellentesque. Etiam ultricies sed mauris vitae hendrerit. Maecenas accumsan ligula vel libero faucibus, in lacinia justo ullamcorper. Etiam pulvinar ex ac odio posuere bibendum. Pellentesque ipsum justo, finibus in egestas ac, dignissim varius neque. Fusce laoreet consequat diam, ut imperdiet libero laoreet quis. Aenean tincidunt a tellus vel posuere. Aenean vel elementum mauris. Pellentesque erat tortor, lobortis ac ullamcorper vitae, sagittis vel arcu. Morbi malesuada, justo ut dignissim mollis, diam nunc consequat enim, nec facilisis ex felis ac dui. -------------------------------------------------------------------------------- /examples/tabs/tab2.gohtml: -------------------------------------------------------------------------------- 1 | Suspendisse blandit, nisl ac porta auctor, mauris nisi elementum enim, non laoreet mauris justo eu sem. Cras eget urna id libero posuere luctus vitae ac lacus. Nunc varius iaculis leo, eu ultrices ligula aliquam non. Suspendisse lacinia magna enim, a ornare leo placerat in. Sed accumsan sapien ligula, sed maximus enim rutrum ut. Fusce leo purus, vestibulum nec dui accumsan, ullamcorper viverra risus. Duis fermentum orci augue, non sagittis orci tempor at. Praesent quis ipsum fermentum, consequat massa at, finibus eros. Praesent nec massa nisi. Proin sed feugiat eros. -------------------------------------------------------------------------------- /examples/tabs/tab3.gohtml: -------------------------------------------------------------------------------- 1 | Morbi elementum varius suscipit. Phasellus congue feugiat sem, vel sodales odio varius eu. Fusce non ex nisi. Aenean nisi dui, tincidunt nec est quis, mollis tempus libero. Fusce placerat pharetra diam, ac mollis turpis bibendum ac. Nullam pulvinar venenatis lacinia. Nam vel quam non ante dignissim bibendum id et ex. Suspendisse potenti. -------------------------------------------------------------------------------- /examples/tabs/templates/content.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 14 | 15 |
16 | {{ selection }} 17 |
18 |
-------------------------------------------------------------------------------- /examples/tabs/templates/footer.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/tabs/templates/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tab Example 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{ child "content" }} 12 |
13 | 14 |
15 |
(rendered on load at : {{ formatDate now "15:04:05" }})
16 |
What the handler looks like:
17 | 18 |
func (a *App) home(w http.ResponseWriter, r *http.Request) {
19 | 	// the tabs for this page.
20 | 	selectMap := map[string]*partial.Partial{
21 | 		"tab1": partial.New("tab1.gohtml"),
22 | 		"tab2": partial.New("tab2.gohtml"),
23 | 		"tab3": partial.New("tab3.gohtml"),
24 | 	}
25 | 
26 | 	// layout, footer, index could be abstracted away and shared over multiple handlers within the same module, for instance.
27 | 	layout := a.PartialService.NewLayout()
28 | 	footer := partial.NewID("footer", "footer.gohtml")
29 | 	index := partial.NewID("index", "index.gohtml").WithOOB(footer)
30 | 
31 | 	content := partial.NewID("content", "content.gohtml").WithSelectMap("tab1", selectMap)
32 | 
33 | 	// set the layout content and wrap it with the main template
34 | 	layout.Set(content).Wrap(index)
35 | 
36 | 	err := layout.WriteWithRequest(r.Context(), w, r)
37 | 	if err != nil {
38 | 		http.Error(w, fmt.Errorf("error rendering layout: %w", err).Error(), http.StatusInternalServerError)
39 | 	}
40 | }
41 |
42 | 43 | {{ child "footer" }} 44 | 45 | 49 | 50 | -------------------------------------------------------------------------------- /examples/tabs/templates/tab1.gohtml: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque pulvinar massa pulvinar eros molestie pellentesque. Mauris facilisis libero leo, non cursus ex facilisis a. Donec tempor metus non ex efficitur, in vestibulum nunc dapibus. Etiam pretium tortor magna, eget tempus lacus varius et. Sed vestibulum velit sed odio facilisis dignissim. Fusce in dolor ac enim consequat cursus et id lorem. Donec convallis lorem dignissim tristique pellentesque. Etiam ultricies sed mauris vitae hendrerit. Maecenas accumsan ligula vel libero faucibus, in lacinia justo ullamcorper. Etiam pulvinar ex ac odio posuere bibendum. Pellentesque ipsum justo, finibus in egestas ac, dignissim varius neque. Fusce laoreet consequat diam, ut imperdiet libero laoreet quis. Aenean tincidunt a tellus vel posuere. Aenean vel elementum mauris. Pellentesque erat tortor, lobortis ac ullamcorper vitae, sagittis vel arcu. Morbi malesuada, justo ut dignissim mollis, diam nunc consequat enim, nec facilisis ex felis ac dui. -------------------------------------------------------------------------------- /examples/tabs/templates/tab2.gohtml: -------------------------------------------------------------------------------- 1 | Suspendisse blandit, nisl ac porta auctor, mauris nisi elementum enim, non laoreet mauris justo eu sem. Cras eget urna id libero posuere luctus vitae ac lacus. Nunc varius iaculis leo, eu ultrices ligula aliquam non. Suspendisse lacinia magna enim, a ornare leo placerat in. Sed accumsan sapien ligula, sed maximus enim rutrum ut. Fusce leo purus, vestibulum nec dui accumsan, ullamcorper viverra risus. Duis fermentum orci augue, non sagittis orci tempor at. Praesent quis ipsum fermentum, consequat massa at, finibus eros. Praesent nec massa nisi. Proin sed feugiat eros. -------------------------------------------------------------------------------- /examples/tabs/templates/tab3.gohtml: -------------------------------------------------------------------------------- 1 | Morbi elementum varius suscipit. Phasellus congue feugiat sem, vel sodales odio varius eu. Fusce non ex nisi. Aenean nisi dui, tincidunt nec est quis, mollis tempus libero. Fusce placerat pharetra diam, ac mollis turpis bibendum ac. Nullam pulvinar venenatis lacinia. Nam vel quam non ante dignissim bibendum id et ex. Suspendisse potenti. -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/donseba/go-partial 2 | 3 | go 1.23.2 4 | -------------------------------------------------------------------------------- /in_memory_fs.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "io/fs" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type InMemoryFS struct { 10 | Files map[string]string 11 | } 12 | 13 | func (f *InMemoryFS) AddFile(name, content string) { 14 | if f.Files == nil { 15 | f.Files = make(map[string]string) 16 | } 17 | f.Files[name] = content 18 | } 19 | 20 | func (f *InMemoryFS) Open(name string) (fs.File, error) { 21 | content, ok := f.Files[name] 22 | if !ok { 23 | return nil, fs.ErrNotExist 24 | } 25 | return &InMemoryFile{ 26 | Reader: strings.NewReader(content), 27 | name: name, 28 | }, nil 29 | } 30 | 31 | type InMemoryFile struct { 32 | *strings.Reader 33 | name string 34 | } 35 | 36 | func (f *InMemoryFile) Stat() (fs.FileInfo, error) { 37 | return &InMemoryFileInfo{name: f.name, size: int64(f.Len())}, nil 38 | } 39 | 40 | func (f *InMemoryFile) ReadDir(count int) ([]fs.DirEntry, error) { 41 | return nil, fs.ErrNotExist 42 | } 43 | 44 | func (f *InMemoryFile) Close() error { 45 | return nil 46 | } 47 | 48 | type InMemoryFileInfo struct { 49 | name string 50 | size int64 51 | } 52 | 53 | func (fi *InMemoryFileInfo) Name() string { return fi.name } 54 | func (fi *InMemoryFileInfo) Size() int64 { return fi.size } 55 | func (fi *InMemoryFileInfo) Mode() fs.FileMode { return 0444 } 56 | func (fi *InMemoryFileInfo) ModTime() time.Time { return time.Time{} } 57 | func (fi *InMemoryFileInfo) IsDir() bool { return false } 58 | func (fi *InMemoryFileInfo) Sys() interface{} { return nil } 59 | -------------------------------------------------------------------------------- /js/README.md: -------------------------------------------------------------------------------- 1 | # partial.js >> The X-Partial Clientside Library 2 | 3 | `This library is part of the x-partial project. X-Partial embraces the hypermedia concept.` 4 | 5 | partial.js is a lightweight JavaScript library designed to simplify AJAX interactions and dynamic content updates in web applications. It enables developers to enhance their web pages with partial page updates, form submissions, custom event handling, and browser history management without the need for full-page reloads. 6 | 7 | ## Installation 8 | Include the partial.js script in your HTML file: 9 | 10 | ```html 11 | 12 | ``` 13 | 14 | ## Getting Started 15 | Instantiate the Partial class in your main JavaScript file: 16 | 17 | ```javascript 18 | const xp = new Partial({ 19 | // Configuration options (optional) 20 | }); 21 | ``` 22 | This will automatically scan the document for elements with x-* attributes and set up the necessary event listeners. 23 | 24 | ## Attributes 25 | partial.js leverages custom HTML attributes to define actions and behaviors: 26 | 27 | ### Action Attributes 28 | Define the HTTP method and URL for the request. 29 | 30 | - `x-get`: Defines a GET request. 31 | - Usage: `` 32 | - `x-post`: Defines a POST request. 33 | - Usage: `
...
` 34 | - `x-put`: Defines a PUT request. 35 | - `x-delete`: Defines a DELETE request. 36 | ### Targeting: 37 | Specify where the response content should be injected. 38 | 39 | - `x-target`: Specifies the CSS selector of the element where the response content will be injected. 40 | - Usage: `` 41 | 42 | ### Trigger Events 43 | Define the event that triggers the action. 44 | 45 | - `x-trigger`: Specifies the event that triggers the action (e.g., `click`, `submit`, `input`). 46 | - Defaults to `click` for most elements and `submit` for forms. 47 | - Usage: `` 48 | 49 | ### Serialization 50 | Control how form data is serialized in the request. 51 | 52 | - `x-serialize`: When set to `json`, `nested-json` or `xml`, form data will be serialized to the selected format. 53 | - Usage: `
...
` 54 | 55 | ### Custom Request Data 56 | Provide custom data to include in the request. 57 | 58 | - `x-json`: Provides a JSON string to be sent as the request body. 59 | - Usage: `` 60 | - `x-params`: Provides JSON parameters to be included in the request. 61 | - Usage: 62 | - With GET requests: Parameters are appended to the URL. 63 | - With other methods: Parameters are merged into the request body. 64 | - Example: 65 | ```html 66 | 67 | ``` 68 | 69 | ### Out-of-Band Swapping 70 | Update elements outside the main content area. 71 | 72 | - `x-swap-oob`: Indicates elements that should be swapped out-of-band. 73 | - When included in a response, elements with x-swap-oob will replace elements with the same id in the current document. 74 | - Usage: In the server response: `
New Notification
` 75 | 76 | ### Browser History Management 77 | Control how the browser history is updated. 78 | 79 | - `x-push-state`: When set to 'false', disables updating the browser history. Defaults to updating history. 80 | - Usage: `` 81 | 82 | ### Focus Management 83 | Control focus behavior after content updates. 84 | 85 | - `x-focus`: When set to 'true', enables auto-focus on the target element after content update. 86 | - Usage: `` 87 | 88 | ### Debouncing 89 | Limit how frequently an event handler can fire. 90 | 91 | - `x-debounce`: Specifies the debounce time in milliseconds for the event handler. 92 | - Usage: `` 93 | 94 | ### Before and After Hooks 95 | Trigger custom events before and after the request. 96 | 97 | - `x-before`: Specifies one or more events (comma-separated) to be dispatched before the request is sent. 98 | - Usage: `` 99 | - `x-after`: Specifies one or more events (comma-separated) to be dispatched after the response is received. 100 | - Usage: `` 101 | 102 | ### Server-Sent Events (SSE) 103 | Establish a connection to receive real-time updates from the server. 104 | 105 | - `x-sse`: Specifies a URL to establish a Server-Sent Events (SSE) connection. 106 | - The element will handle incoming SSE messages from the specified URL. 107 | - Usage: `
` 108 | 109 | ### Loading Indicators 110 | Display an indicator during the request. 111 | 112 | - `x-indicator`: Specifies a selector for an element to show before the request is sent and hide after the response is received. 113 | - Useful for displaying a loading spinner or message. 114 | - Usage: 115 | ```html 116 | 117 | 118 | ``` 119 | 120 | ### Confirmation Prompt 121 | Prompt the user for confirmation before proceeding. 122 | 123 | - `x-confirm`: Specifies a confirmation message to display before proceeding with the action. 124 | - If the user cancels, the action is aborted. 125 | - Usage: `` 126 | 127 | ### Request Timeout 128 | Set a maximum time to wait for a response. 129 | 130 | - `x-timeout`: Specifies a timeout in milliseconds for the request. 131 | - If the request does not complete within this time, it will be aborted. 132 | - Usage: `` 133 | 134 | ### Request Retries 135 | Automatically retry failed requests. 136 | 137 | - `x-retry`: Specifies the number of times to retry the request if it fails. 138 | - Usage: `` 139 | 140 | ### Custom Error Handling 141 | Define custom behavior when an error occurs. 142 | 143 | - `x-on-error`: Specifies the name of a global function to call if an error occurs during the request. 144 | - Usage: 145 | ```javascript 146 | 151 | 152 | ``` 153 | 154 | 155 | ### Loading Classes 156 | Apply CSS classes to elements during the request. 157 | - `x-loading-class`: Specifies a CSS class to add to the target element during the request. The class is removed after the request completes. 158 | - Useful for adding styles like opacity changes or loading animations. 159 | - Usage: 160 | ```html 161 | 166 | 167 | ``` 168 | 169 | ## Configuration Options 170 | When instantiating partial.js, you can provide a configuration object to customize its behavior: 171 | 172 | ```javascript 173 | const xp = new Partial({ 174 | onError: (error, element) => { 175 | // Custom error handling 176 | }, 177 | csrfToken: 'your-csrf-token' || (() => /* return token */), 178 | beforeRequest: async ({ method, url, headers, element }) => { 179 | // Logic before the request is sent 180 | }, 181 | afterResponse: async ({ response, element }) => { 182 | // Logic after the response is received 183 | }, 184 | autoFocus: true, // Automatically focus the target element (default: true) 185 | debounceTime: 200, // Debounce event handlers by 200 milliseconds (default: 0) 186 | defaultSwapOption: 'innerHTML', // Default content swap method ('outerHTML' or 'innerHTML') 187 | }); 188 | ``` 189 | 190 | ### Available Options: 191 | 192 | - `onError` (Function): Callback function to handle errors. Receives error and element as arguments. 193 | - `csrfToken` (Function or string): CSRF token value or a function that returns the token. Automatically included in request headers as X-CSRF-Token. 194 | - `beforeRequest` (Function): Hook function called before a request is sent. Receives an object with method, url, headers, and element. 195 | - `afterResponse` (Function): Hook function called after a response is received. Receives an object with response and element. 196 | - `autoFocus` (boolean): Whether to auto-focus the target element after content update. Default is true. 197 | - `debounceTime` (number): Debounce time in milliseconds for event handlers. Default is 0 (no debounce). 198 | - `defaultSwapOption` ('outerHTML' | 'innerHTML'): Default swap method for content replacement. Default is 'outerHTML'. 199 | 200 | ## Class Overview 201 | 202 | ### Partial 203 | 204 | The main class that handles scanning the DOM, setting up event listeners, making AJAX requests, updating the DOM based on responses, and managing browser history. 205 | 206 | #### Parameters: 207 | 208 | - `options` (Object): Configuration options (see Configuration Options). 209 | 210 | #### Description: 211 | 212 | Initializes the Partial instance, sets up action attributes, binds methods, and sets up event listeners. It automatically scans the document for elements with action attributes on DOMContentLoaded and listens for popstate events for browser navigation. 213 | 214 | ### event 215 | ```javascript 216 | xp.event(eventName, callback, options) 217 | ``` 218 | 219 | #### Parameters: 220 | - `eventName` (string): The name of the event to listen for. 221 | - `callback` (Function): The function to call when the event is dispatched. 222 | - `options` (boolean | AddEventListenerOptions): Optional event listener options. 223 | 224 | #### Description: 225 | Registers an event listener for a custom event dispatched by Partial. 226 | 227 | ### removeEvent 228 | ```javascript 229 | xp.event(eventName, callback, options) 230 | ``` 231 | 232 | #### Parameters: 233 | - `eventName` (string): The name of the event to listen for. 234 | - `callback` (Function): The function to call when the event is dispatched. 235 | - `options` (boolean | AddEventListenerOptions): Optional event listener options. 236 | 237 | #### Description: 238 | Registers an event listener for a custom event dispatched by Partial. 239 | 240 | ### removeAllEvents 241 | 242 | #### Parameters: 243 | - `eventName` (string): The name of the event. 244 | 245 | #### Description: 246 | Removes all event listeners registered for the specified event name. 247 | 248 | ### refresh 249 | ```javascript 250 | xp.refresh(container = document) 251 | ``` 252 | 253 | #### Parameters: 254 | - `container` (HTMLElement): The container element to scan for x-* attributes. Defaults to the entire document. 255 | 256 | #### Description: 257 | Manually re-scans a specific container for Partial elements. Useful when dynamically adding content to the DOM. 258 | 259 | ## Usage Examples 260 | 261 | ### Basic Link Click 262 | ```html 263 | 264 | Load Content 265 | 266 |
267 | 268 |
269 | ``` 270 | 271 | #### Description: 272 | When the link is clicked, Partial intercepts the click event, performs a GET request to /new-content, and injects the response into the element with ID content. The browser history is updated accordingly. 273 | 274 | ### Form Submission 275 | ```html 276 | 277 |
278 | 279 | 280 |
281 | 282 |
283 | 284 |
285 | ``` 286 | 287 | #### Description: 288 | Upon form submission, Partial sends a POST request to /submit, serializes the form data as JSON, and updates the #content element with the response. The default form submission is prevented. 289 | 290 | ### Handling Custom Events 291 | ```javascript 292 | // JavaScript 293 | xp.event('notify', (event) => { 294 | alert(event.detail.message); 295 | }); 296 | ``` 297 | 298 | #### Server Response Headers: 299 | ```css 300 | X-Event-Notify: {"message": "Operation successful"} 301 | ``` 302 | 303 | #### Description: 304 | When the server responds with an X-Event-Notify header, Partial dispatches a notify event. The registered event listener displays an alert with the message. 305 | 306 | ### Out-of-Band (OOB) Swapping 307 | ```html 308 | 309 | 310 | 311 | 312 | 313 |
314 | Current status: Active 315 |
316 | ``` 317 | #### Server Response: 318 | ```html 319 | 320 |
321 | 322 |
323 | 324 | 325 |
326 | Current status: Inactive 327 |
328 | ``` 329 | 330 | #### Description: 331 | The OOB element with x-swap-oob is processed by Partial to update the #status element even though it's outside the main #content area. 332 | 333 | ### Browser History Management 334 | ```html 335 | 336 | Go to Page 2 337 | 338 |
339 | 340 |
341 | ``` 342 | 343 | #### Description: 344 | When the link is clicked, Partial updates the content and uses history.pushState to update the browser's URL. The popstate event handler ensures that navigating back and forward works correctly by reloading content based on the current URL. 345 | 346 | 347 | ## Advanced Features 348 | ### Custom Headers 349 | Add custom headers by using x-* attributes. For example: 350 | ```html 351 | 352 | ``` 353 | #### Description: 354 | This will send a request to /data with a header X-Custom-Header: value. 355 | 356 | 357 | ### Event Lifecycle Hooks 358 | Partial provides hooks to execute custom logic before and after requests: 359 | 360 | - beforeRequest Hook: 361 | ```javascript 362 | const xp = new Partial({ 363 | beforeRequest: async ({ method, url, headers, element }) => { 364 | // Logic before the request is sent 365 | }, 366 | }); 367 | ``` 368 | - afterResponse Hook: 369 | ```javascript 370 | const xp = new Partial({ 371 | afterResponse: async ({ response, element }) => { 372 | // Logic after the response is received 373 | }, 374 | }); 375 | ``` 376 | 377 | ### Debounce Functionality 378 | Prevent rapid, repeated triggering of event handlers: 379 | ```javascript 380 | const xp = new Partial({ 381 | debounceTime: 300, // Debounce by 300 milliseconds 382 | }); 383 | ``` 384 | #### Description: 385 | This is particularly useful for events like input or rapid clicks, ensuring the event handler is not called more often than the specified debounce time. 386 | 387 | ### Focus Management 388 | Control whether the target element receives focus after content is updated: 389 | 390 | - Globally Enable Auto-Focus: 391 | ```javascript 392 | const xp = new Partial({ 393 | autoFocus: true, 394 | }); 395 | ``` 396 | - Disable Auto-Focus on Specific Elements: 397 | ```html 398 | 399 | ``` 400 | 401 | 402 | ## Contributing 403 | Contributions are welcome! Please submit issues and pull requests on the GitHub repository. 404 | 405 | ## License 406 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /js/partial.htmx.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | htmx.on('htmx:configRequest', function(event) { 3 | let element = event.detail.elt; 4 | let selectValue = element.getAttribute('x-select'); 5 | if (selectValue !== null) { 6 | event.detail.headers['X-Select'] = selectValue; 7 | } 8 | 9 | let actionValue = element.getAttribute('x-action'); 10 | if (actionValue !== null) { 11 | event.detail.headers['X-Action'] = actionValue; 12 | } 13 | }); 14 | })(); -------------------------------------------------------------------------------- /js/partial.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {Object} PartialOptions 5 | * @property {'outerHTML'|'innerHTML'} [defaultSwapOption='outerHTML'] - Default swap method. 6 | * @property {Function} [onError] - Callback function for handling errors. 7 | * @property {Function|string} [csrfToken] - CSRF token value or function returning the token. 8 | * @property {Function} [beforeRequest] - Hook before the request is sent. 9 | * @property {Function} [afterResponse] - Hook after the response is received. 10 | * @property {boolean} [autoFocus=false] - Whether to auto-focus the target element after content update. 11 | * @property {number} [debounceTime=0] - Debounce time in milliseconds for event handlers. 12 | */ 13 | 14 | /** 15 | * @typedef {Object} SseMessage 16 | * @property {string} content - The HTML content to insert. 17 | * @property {string} [xTarget] - The CSS selector for the target element. 18 | * @property {string} [xFocus] - Whether to focus the target element ('true' or 'false'). 19 | * @property {string} [xSwap] - The swap method ('outerHTML' or 'innerHTML'). 20 | * @property {string} [xEvent] - Custom events to dispatch. 21 | */ 22 | 23 | /** 24 | * Class representing Partial.js. 25 | */ 26 | class Partial { 27 | /** 28 | * Creates an instance of Partial. 29 | * @param {PartialOptions} [options={}] - Configuration options. 30 | */ 31 | constructor(options = {}) { 32 | // Define the custom action attributes 33 | this.ATTRIBUTES = { 34 | ACTIONS: { 35 | GET: 'x-get', 36 | POST: 'x-post', 37 | PUT: 'x-put', 38 | DELETE: 'x-delete', 39 | PATCH: 'x-patch', 40 | }, 41 | TARGET: 'x-target', 42 | TRIGGER: 'x-trigger', 43 | SERIALIZE: 'x-serialize', 44 | JSON: 'x-json', 45 | PARAMS: 'x-params', 46 | SWAP_OOB: 'x-swap-oob', 47 | PUSH_STATE: 'x-push-state', 48 | FOCUS: 'x-focus', 49 | DEBOUNCE: 'x-debounce', 50 | BEFORE: 'x-before', 51 | AFTER: 'x-after', 52 | SSE: 'x-sse', 53 | INDICATOR: 'x-indicator', 54 | CONFIRM: 'x-confirm', 55 | TIMEOUT: 'x-timeout', 56 | RETRY: 'x-retry', 57 | ON_ERROR: 'x-on-error', 58 | LOADING_CLASS: 'x-loading-class', 59 | SWAP: 'x-swap', 60 | INFINITE_SCROLL: 'x-infinite-scroll', 61 | }; 62 | 63 | this.SERIALIZE_TYPES = { 64 | JSON: 'json', 65 | NESTED_JSON: 'nested-json', 66 | XML: 'xml', 67 | }; 68 | 69 | this.INHERITABLE_ATTRIBUTES = [ 70 | this.ATTRIBUTES.TARGET, 71 | this.ATTRIBUTES.SWAP, 72 | this.ATTRIBUTES.SERIALIZE, 73 | this.ATTRIBUTES.TRIGGER, 74 | this.ATTRIBUTES.LOADING_CLASS, 75 | this.ATTRIBUTES.INDICATOR, 76 | this.ATTRIBUTES.RETRY, 77 | this.ATTRIBUTES.TIMEOUT, 78 | this.ATTRIBUTES.FOCUS, 79 | this.ATTRIBUTES.DEBOUNCE, 80 | ]; 81 | 82 | // Store options with default values 83 | this.onError = options.onError || null; 84 | this.csrfToken = options.csrfToken || null; 85 | this.defaultSwapOption = options.defaultSwapOption || 'outerHTML'; 86 | this.beforeRequest = options.beforeRequest || null; 87 | this.afterResponse = options.afterResponse || null; 88 | this.autoFocus = options.autoFocus !== undefined ? options.autoFocus : false; 89 | this.debounceTime = options.debounceTime || 0; 90 | 91 | this.eventTarget = new EventTarget(); 92 | this.eventListeners = {}; 93 | 94 | // Map to store SSE connections per element 95 | this.sseConnections = new Map(); 96 | 97 | // Bind methods to ensure correct 'this' context 98 | this.scanForElements = this.scanForElements.bind(this); 99 | this.setupElement = this.setupElement.bind(this); 100 | this.setupSSEElement = this.setupSSEElement.bind(this); 101 | this.setupInfiniteScroll = this.setupInfiniteScroll.bind(this); 102 | this.stopInfiniteScroll = this.stopInfiniteScroll.bind(this); 103 | this.handleAction = this.handleAction.bind(this); 104 | this.handleOobSwapping = this.handleOobSwapping.bind(this); 105 | this.handlePopState = this.handlePopState.bind(this); 106 | this.handleInfiniteScrollAction = this.handleInfiniteScrollAction.bind(this); 107 | 108 | // Initialize the handler on DOMContentLoaded 109 | document.addEventListener('DOMContentLoaded', () => this.scanForElements()); 110 | 111 | // Listen for popstate events 112 | window.addEventListener('popstate', this.handlePopState); 113 | } 114 | 115 | // Initialization Methods 116 | // ---------------------- 117 | 118 | /** 119 | * Scans the entire document or a specific container for elements with defined action attributes. 120 | * @param {HTMLElement | Document} [container=document] 121 | */ 122 | scanForElements(container = document) { 123 | const actionSelector = Object.values(this.ATTRIBUTES.ACTIONS).map(attr => `[${attr}]`).join(','); 124 | const sseSelector = `[${this.ATTRIBUTES.SSE}]`; 125 | const combinedSelector = `${actionSelector}, ${sseSelector}`; 126 | const elements = container.querySelectorAll(combinedSelector); 127 | 128 | elements.forEach(element => { 129 | if (element.hasAttribute(this.ATTRIBUTES.SSE)) { 130 | this.setupSSEElement(element); 131 | } else { 132 | this.setupElement(element); 133 | } 134 | }); 135 | } 136 | 137 | // SSE Methods 138 | // ----------- 139 | 140 | /** 141 | * Sets up an element with x-sse attribute to handle SSE connections. 142 | * @param {HTMLElement} element 143 | */ 144 | setupSSEElement(element) { 145 | // Avoid attaching multiple listeners 146 | if (element.__xSSEInitialized) return; 147 | 148 | const sseUrl = element.getAttribute(this.ATTRIBUTES.SSE); 149 | if (!sseUrl) { 150 | console.error('No URL specified in x-sse attribute on element:', element); 151 | return; 152 | } 153 | 154 | const eventSource = new EventSource(sseUrl); 155 | 156 | eventSource.onmessage = (event) => { 157 | this.handleSSEMessage(event, element).catch(error => { 158 | this.handleError(error, element); 159 | }); 160 | }; 161 | 162 | eventSource.onerror = (error) => { 163 | this.handleError(error, element); 164 | }; 165 | 166 | // Store the connection to manage it later if needed 167 | this.sseConnections.set(element, eventSource); 168 | 169 | // Mark the element as initialized 170 | element.__xSSEInitialized = true; 171 | } 172 | 173 | /** 174 | * Handles incoming SSE messages for a specific element. 175 | * @param {MessageEvent} event 176 | * @param {HTMLElement} element 177 | */ 178 | async handleSSEMessage(event, element) { 179 | try { 180 | /** @type {SseMessage} */ 181 | const data = JSON.parse(event.data); 182 | 183 | const targetSelector = data.xTarget; 184 | const targetElement = document.querySelector(targetSelector); 185 | 186 | if (!targetElement) { 187 | console.error(`No element found with selector '${targetSelector}' for SSE message.`); 188 | return; 189 | } 190 | 191 | // Decide swap method 192 | const swapOption = data.xSwap || this.defaultSwapOption; 193 | 194 | this.performSwap(targetElement, data.content, swapOption); 195 | 196 | // Optionally focus the target element 197 | const focusEnabled = data.xFocus !== 'false'; 198 | if (this.autoFocus && focusEnabled) { 199 | const newTargetElement = document.querySelector(targetSelector); 200 | if (newTargetElement) { 201 | if (newTargetElement.getAttribute('tabindex') === null) { 202 | newTargetElement.setAttribute('tabindex', '-1'); 203 | } 204 | newTargetElement.focus(); 205 | } 206 | } 207 | 208 | // Re-scan the updated content for Partial elements 209 | this.scanForElements(); 210 | 211 | // Dispatch custom events if specified 212 | if (data.xEvent) { 213 | await this.dispatchCustomEvents(data.xEvent, { element, event, data }); 214 | } 215 | 216 | // Dispatch an event after the content is replaced 217 | this.dispatchEvent('sseContentReplaced', { targetElement, data, element }); 218 | 219 | } catch (error) { 220 | this.handleError(error, element); 221 | } 222 | } 223 | 224 | // Element Setup Methods 225 | // --------------------- 226 | 227 | /** 228 | * Sets up an individual element by attaching the appropriate event listener. 229 | * @param {HTMLElement} element 230 | */ 231 | setupElement(element) { 232 | // Avoid attaching multiple listeners 233 | if (element.__xRequestHandlerInitialized) return; 234 | 235 | // Check for x-infinite-scroll attribute 236 | if (element.hasAttribute(this.ATTRIBUTES.INFINITE_SCROLL)) { 237 | this.setupInfiniteScroll(element); 238 | // Mark the element as initialized 239 | element.__xRequestHandlerInitialized = true; 240 | return; 241 | } 242 | 243 | // Set a default trigger based on the element type 244 | let trigger; 245 | if (element.tagName === 'FORM') { 246 | trigger = element.getAttribute(this.ATTRIBUTES.TRIGGER) || 'submit'; 247 | } else { 248 | trigger = this.getAttributeWithInheritance(element, this.ATTRIBUTES.TRIGGER) || 'click'; 249 | } 250 | 251 | // Get custom debounce time from x-debounce attribute 252 | let elementDebounceTime = this.debounceTime; // Default to global debounce time 253 | const xDebounce = this.getAttributeWithInheritance(element, this.ATTRIBUTES.DEBOUNCE); 254 | if (xDebounce !== null) { 255 | const parsedDebounce = parseInt(xDebounce, 10); 256 | if (!isNaN(parsedDebounce) && parsedDebounce >= 0) { 257 | elementDebounceTime = parsedDebounce; 258 | } else { 259 | console.warn(`Invalid x-debounce value '${xDebounce}' on element:`, element); 260 | } 261 | } 262 | 263 | // Debounce only the handleAction function 264 | const debouncedHandleAction = this.debounce((event) => { 265 | this.handleAction(event, element).catch(error => { 266 | this.handleError(error, element); 267 | }); 268 | }, elementDebounceTime); 269 | 270 | // Event handler that calls preventDefault immediately 271 | const handler = (event) => { 272 | event.preventDefault(); 273 | debouncedHandleAction(event); 274 | }; 275 | 276 | element.addEventListener(trigger, handler); 277 | 278 | // Mark the element as initialized 279 | element.__xRequestHandlerInitialized = true; 280 | } 281 | 282 | // Infinite Scroll Methods 283 | // ----------------------- 284 | 285 | /** 286 | * Sets up infinite scroll on an element. 287 | * @param {HTMLElement} parentElement 288 | */ 289 | setupInfiniteScroll(parentElement) { 290 | // Check if infinite scroll has been stopped 291 | if (parentElement.__infiniteScrollStopped) { 292 | return; 293 | } 294 | 295 | // Create or find the sentinel element 296 | let sentinel = parentElement.__sentinelElement; 297 | if (!sentinel) { 298 | sentinel = document.createElement('div'); 299 | sentinel.classList.add('infinite-scroll-sentinel'); 300 | parentElement.parentNode.insertBefore(sentinel, parentElement.nextSibling); 301 | parentElement.__sentinelElement = sentinel; 302 | } 303 | 304 | // Set up Intersection Observer on the sentinel 305 | const observer = new IntersectionObserver((entries) => { 306 | entries.forEach(entry => { 307 | if (entry.isIntersecting) { 308 | // Unobserve to prevent multiple triggers 309 | observer.unobserve(sentinel); 310 | // Execute the action 311 | this.handleInfiniteScrollAction(parentElement).catch(error => { 312 | this.handleError(error, parentElement); 313 | }); 314 | } 315 | }); 316 | }); 317 | 318 | observer.observe(sentinel); 319 | 320 | // Store the observer reference 321 | parentElement.__infiniteScrollObserver = observer; 322 | } 323 | 324 | /** 325 | * Stops the infinite scroll by removing the sentinel and disconnecting the observer. 326 | * @param {HTMLElement} parentElement 327 | */ 328 | stopInfiniteScroll(parentElement) { 329 | // Remove the sentinel element 330 | if (parentElement.__sentinelElement) { 331 | parentElement.__sentinelElement.remove(); 332 | delete parentElement.__sentinelElement; 333 | } 334 | 335 | // Set a flag to indicate infinite scroll has stopped 336 | parentElement.__infiniteScrollStopped = true; 337 | 338 | // Disconnect the observer 339 | if (parentElement.__infiniteScrollObserver) { 340 | parentElement.__infiniteScrollObserver.disconnect(); 341 | delete parentElement.__infiniteScrollObserver; 342 | } 343 | } 344 | 345 | /** 346 | * Handles the action for infinite scroll. 347 | * @param {HTMLElement} parentElement 348 | */ 349 | async handleInfiniteScrollAction(parentElement) { 350 | const url = parentElement.getAttribute(this.ATTRIBUTES.ACTIONS.GET); 351 | if (!url) { 352 | console.error('No URL specified for infinite scroll.'); 353 | return; 354 | } 355 | 356 | const requestParams = this.prepareRequestParams(parentElement, { maxRetries: 2 }); 357 | 358 | // Set X-Action header if not already set 359 | if (!requestParams.headers["X-Action"]) { 360 | requestParams.headers["X-Action"] = "infinite-scroll"; 361 | } 362 | 363 | // Get the params from the last child 364 | requestParams.paramsObject = this.getChildParamsObject(parentElement); 365 | if (requestParams.paramsObject && Object.keys(requestParams.paramsObject).length > 0) { 366 | requestParams.headers["X-Params"] = JSON.stringify(requestParams.paramsObject); 367 | } 368 | 369 | try { 370 | const responseText = await this.performRequest(requestParams); 371 | const targetElement = document.querySelector(requestParams.targetSelector); 372 | if (!targetElement) { 373 | console.error(`No element found with selector '${requestParams.targetSelector}' for infinite scroll.`); 374 | return; 375 | } 376 | 377 | await this.processResponse(responseText, targetElement, parentElement); 378 | 379 | // Re-attach the observer to continue loading 380 | this.setupInfiniteScroll(parentElement); 381 | } catch (error) { 382 | this.handleError(error, parentElement, parentElement); 383 | } 384 | } 385 | 386 | /** 387 | * Retrieves parameters from the last child element. 388 | * @param {HTMLElement} parentElement 389 | * @returns {Object} 390 | */ 391 | getChildParamsObject(parentElement) { 392 | // Get x-params from the last child 393 | const lastChild = parentElement.lastElementChild; 394 | let paramsObject = {}; 395 | if (lastChild) { 396 | const xParamsAttr = lastChild.getAttribute(this.ATTRIBUTES.PARAMS); 397 | if (xParamsAttr) { 398 | try { 399 | paramsObject = JSON.parse(xParamsAttr); 400 | } catch (e) { 401 | console.error('Invalid JSON in x-params attribute of last child:', e); 402 | } 403 | } 404 | } 405 | 406 | return paramsObject; 407 | } 408 | 409 | // Action Handling Methods 410 | // ----------------------- 411 | 412 | /** 413 | * Handles the action when an element is triggered. 414 | * @param {Event} event 415 | * @param {HTMLElement} element 416 | */ 417 | async handleAction(event, element) { 418 | // Get a confirmation message from x-confirm 419 | const confirmMessage = element.getAttribute(this.ATTRIBUTES.CONFIRM); 420 | if (confirmMessage) { 421 | const confirmed = window.confirm(confirmMessage); 422 | if (!confirmed) { 423 | return; // Abort the action 424 | } 425 | } 426 | 427 | // Get the indicator selector from x-indicator 428 | const indicatorSelector = this.getAttributeWithInheritance(element, this.ATTRIBUTES.INDICATOR); 429 | let indicatorElement = null; 430 | if (indicatorSelector) { 431 | indicatorElement = document.querySelector(indicatorSelector); 432 | } 433 | 434 | // Get loading class from x-loading-class 435 | const loadingClass = this.getAttributeWithInheritance(element, this.ATTRIBUTES.LOADING_CLASS); 436 | 437 | // Handle x-focus 438 | const focusEnabled = this.getAttributeWithInheritance(element, this.ATTRIBUTES.FOCUS) !== 'false'; 439 | 440 | // Handle x-push-state 441 | const shouldPushState = this.getAttributeWithInheritance(element, this.ATTRIBUTES.PUSH_STATE) !== 'false'; 442 | 443 | // Handle x-timeout 444 | const timeoutValue = this.getAttributeWithInheritance(element, this.ATTRIBUTES.TIMEOUT); 445 | const timeout = parseInt(timeoutValue, 10); 446 | 447 | // Handle x-retry 448 | const retryValue = this.getAttributeWithInheritance(element, this.ATTRIBUTES.RETRY); 449 | const maxRetries = parseInt(retryValue, 10) || 0; 450 | 451 | const requestParams = this.prepareRequestParams(element); 452 | 453 | const targetElement = document.querySelector(requestParams.targetSelector); 454 | if (!targetElement) { 455 | const error = new Error(`No element found with selector '${requestParams.targetSelector}' for 'x-target' targeting.`); 456 | this.handleError(error, element, targetElement); 457 | return; 458 | } 459 | 460 | try { 461 | // Show the indicator before the request 462 | if (indicatorElement) { 463 | indicatorElement.style.display = ''; // Or apply a CSS class to show 464 | } 465 | 466 | // Add loading class to target element 467 | if (loadingClass && targetElement) { 468 | targetElement.classList.add(loadingClass); 469 | } 470 | 471 | // Dispatch x-before event(s) if specified 472 | const beforeEvents = element.getAttribute(this.ATTRIBUTES.BEFORE); 473 | if (beforeEvents) { 474 | await this.dispatchCustomEvents(beforeEvents, { element, event }); 475 | } 476 | 477 | // Before request hook 478 | if (typeof this.beforeRequest === 'function') { 479 | await this.beforeRequest({ ...requestParams, element }); 480 | } 481 | 482 | // Dispatch beforeSend event 483 | this.dispatchEvent('beforeSend', { ...requestParams, element }); 484 | 485 | // Call performRequest with the correct parameters 486 | const responseText = await this.performRequest({ 487 | ...requestParams, 488 | timeout, 489 | maxRetries, 490 | }); 491 | 492 | // After response hook 493 | if (typeof this.afterResponse === 'function') { 494 | await this.afterResponse({ response: this.lastResponse, element }); 495 | } 496 | 497 | // Dispatch afterReceive event 498 | this.dispatchEvent('afterReceive', { response: this.lastResponse, element }); 499 | 500 | // Process and update the DOM with the response 501 | await this.processResponse(responseText, targetElement, element); 502 | 503 | // After successfully updating content 504 | if (shouldPushState) { 505 | const newUrl = new URL(requestParams.url, window.location.origin); 506 | history.pushState({ xPartial: true }, '', newUrl); 507 | } 508 | 509 | // Dispatch x-after event(s) if specified 510 | const afterEvents = element.getAttribute(this.ATTRIBUTES.AFTER); 511 | if (afterEvents) { 512 | await this.dispatchCustomEvents(afterEvents, { element, event }); 513 | } 514 | 515 | // Auto-focus if enabled 516 | if (this.autoFocus && focusEnabled) { 517 | if (targetElement.getAttribute('tabindex') === null) { 518 | targetElement.setAttribute('tabindex', '-1'); 519 | } 520 | targetElement.focus(); 521 | } 522 | 523 | } catch (error) { 524 | const onErrorAttr = element.getAttribute(this.ATTRIBUTES.ON_ERROR); 525 | if (onErrorAttr && typeof window[onErrorAttr] === 'function') { 526 | window[onErrorAttr](error, element); 527 | } else if (typeof this.onError === 'function') { 528 | this.onError(error, element); 529 | } else { 530 | // Default error handling 531 | console.error('Request failed:', error); 532 | targetElement.innerHTML = `
An error occurred: ${error.message}
`; 533 | } 534 | } finally { 535 | // Hide the indicator after the request completes or fails 536 | if (indicatorElement) { 537 | indicatorElement.style.display = 'none'; // Or remove the CSS class 538 | } 539 | 540 | // Remove loading class from target element 541 | if (loadingClass && targetElement) { 542 | targetElement.classList.remove(loadingClass); 543 | } 544 | } 545 | } 546 | 547 | // Request Preparation Methods 548 | // --------------------------- 549 | 550 | /** 551 | * Prepares the request parameters for the Fetch API. 552 | * @param {HTMLElement} element 553 | * @param {Object} [additionalParams={}] 554 | * @returns {Object} Request parameters 555 | */ 556 | prepareRequestParams(element, additionalParams = {}) { 557 | const requestParams = this.extractRequestParams(element); 558 | requestParams.element = element; 559 | 560 | if (!requestParams.url) { 561 | throw new Error(`No URL specified for method ${requestParams.method} on element.`); 562 | } 563 | 564 | const targetElement = document.querySelector(requestParams.targetSelector); 565 | if (!targetElement) { 566 | throw new Error(`No element found with selector '${requestParams.targetSelector}' for 'x-target' targeting.`); 567 | } 568 | 569 | if (!requestParams.partialId) { 570 | throw new Error(`Target element does not have an 'id' attribute.`); 571 | } 572 | 573 | // Set the X-Target header 574 | requestParams.headers["X-Target"] = requestParams.partialId; 575 | 576 | // Merge additional parameters 577 | Object.assign(requestParams, additionalParams); 578 | 579 | return requestParams; 580 | } 581 | 582 | /** 583 | * Extracts request parameters from the element. 584 | * @param {HTMLElement} element 585 | * @returns {Object} Parameters including method, url, headers, body, etc. 586 | */ 587 | extractRequestParams(element) { 588 | const method = this.getMethod(element); 589 | const actionAttr = `x-${method.toLowerCase()}`; 590 | let url = this.getAttributeWithInheritance(element, actionAttr); 591 | 592 | const headers = this.getHeaders(element); 593 | 594 | let targetSelector = this.getAttributeWithInheritance(element, this.ATTRIBUTES.TARGET); 595 | if (!targetSelector) { 596 | targetSelector = element.id ? `#${element.id}` : "body"; 597 | } 598 | 599 | const targetElement = document.querySelector(targetSelector); 600 | const partialId = targetElement ? targetElement.getAttribute('id') : null; 601 | 602 | const xParams = this.getAttributeWithInheritance(element, this.ATTRIBUTES.PARAMS); 603 | let paramsObject = {}; 604 | 605 | if (xParams) { 606 | try { 607 | paramsObject = JSON.parse(xParams); 608 | } catch (e) { 609 | const error = new Error('Invalid JSON in x-params attribute'); 610 | this.handleError(error, element, targetElement); 611 | } 612 | } 613 | 614 | return { method, url, headers, targetSelector, partialId, paramsObject }; 615 | } 616 | 617 | /** 618 | * Determines the HTTP method based on the element's attributes. 619 | * @param {HTMLElement} element 620 | * @returns {string} HTTP method 621 | */ 622 | getMethod(element) { 623 | for (const attr of Object.values(this.ATTRIBUTES.ACTIONS)) { 624 | if (this.hasAttributeWithInheritance(element, attr)) { 625 | return attr.replace('x-', '').toUpperCase(); 626 | } 627 | } 628 | return 'GET'; // Default method 629 | } 630 | 631 | /** 632 | * Constructs headers from the element's attributes. 633 | * @param {HTMLElement} element 634 | * @returns {Object} Headers object 635 | */ 636 | getHeaders(element) { 637 | const headers = {}; 638 | 639 | if (this.csrfToken) { 640 | if (typeof this.csrfToken === 'function') { 641 | headers['X-CSRF-Token'] = this.csrfToken(); 642 | } else { 643 | headers['X-CSRF-Token'] = this.csrfToken; 644 | } 645 | } 646 | 647 | // List of attributes to exclude from headers 648 | const excludedAttributes = [ 649 | ...Object.values(this.ATTRIBUTES.ACTIONS), 650 | this.ATTRIBUTES.TARGET, 651 | this.ATTRIBUTES.TRIGGER, 652 | this.ATTRIBUTES.SWAP, 653 | this.ATTRIBUTES.SWAP_OOB, 654 | this.ATTRIBUTES.PUSH_STATE, 655 | this.ATTRIBUTES.INFINITE_SCROLL, 656 | this.ATTRIBUTES.DEBOUNCE 657 | ]; 658 | 659 | // Collect x-* attributes to include as headers 660 | for (const attr of element.attributes) { 661 | const name = attr.name; 662 | if (name.startsWith('x-') && !excludedAttributes.includes(name)) { 663 | const headerName = 'X-' + this.capitalize(name.substring(2)); // Remove 'x-' prefix and capitalize 664 | headers[headerName] = attr.value; 665 | } 666 | } 667 | 668 | return headers; 669 | } 670 | 671 | // Utility Methods 672 | // --------------- 673 | 674 | /** 675 | * Retrieves the value of an attribute from the element or its ancestors. 676 | * @param {HTMLElement} element 677 | * @param {string} attributeName 678 | * @returns {string|null} 679 | */ 680 | getAttributeWithInheritance(element, attributeName) { 681 | if (!this.INHERITABLE_ATTRIBUTES.includes(attributeName)) { 682 | return element.getAttribute(attributeName); 683 | } 684 | 685 | let currentElement = element; 686 | while (currentElement) { 687 | if (currentElement.hasAttribute(attributeName)) { 688 | return currentElement.getAttribute(attributeName); 689 | } 690 | currentElement = currentElement.parentElement; 691 | } 692 | return null; 693 | } 694 | 695 | /** 696 | * Checks if an attribute exists on the element or its ancestors. 697 | * @param {HTMLElement} element 698 | * @param {string} attributeName 699 | * @returns {boolean} 700 | */ 701 | hasAttributeWithInheritance(element, attributeName) { 702 | return this.getAttributeWithInheritance(element, attributeName) !== null; 703 | } 704 | 705 | /** 706 | * Capitalizes the first letter of the string. 707 | * @param {string} str 708 | * @returns {string} 709 | */ 710 | capitalize(str) { 711 | return str.charAt(0).toUpperCase() + str.slice(1); 712 | } 713 | 714 | /** 715 | * Debounce function to limit the rate at which a function can fire. 716 | * @param {Function} func - The function to debounce. 717 | * @param {number} wait - The number of milliseconds to wait. 718 | * @returns {Function} 719 | */ 720 | debounce(func, wait) { 721 | let timeout; 722 | return (...args) => { 723 | const later = () => { 724 | clearTimeout(timeout); 725 | func.apply(this, args); 726 | }; 727 | clearTimeout(timeout); 728 | timeout = setTimeout(later, wait); 729 | }; 730 | } 731 | 732 | // Request Execution Methods 733 | // ------------------------- 734 | 735 | /** 736 | * Performs the HTTP request using Fetch API. 737 | * @param {Object} requestParams - Parameters including method, url, headers, body, etc. 738 | * @returns {Promise} Response text 739 | */ 740 | async performRequest(requestParams) { 741 | const { method, url, headers, element, timeout, maxRetries, paramsObject } = requestParams; 742 | let requestUrl = url; 743 | 744 | const controller = new AbortController(); 745 | const options = { 746 | method, 747 | headers, 748 | credentials: 'same-origin', 749 | signal: controller.signal, 750 | }; 751 | 752 | // Handle x-serialize attribute 753 | const serializeType = element && ( 754 | element.getAttribute(this.ATTRIBUTES.SERIALIZE) || 755 | (element.closest('form') && element.closest('form').getAttribute(this.ATTRIBUTES.SERIALIZE)) 756 | ); 757 | 758 | // Check for x-json attribute 759 | const xJson = element && element.getAttribute(this.ATTRIBUTES.JSON); 760 | 761 | // Handle request body 762 | if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { 763 | let bodyData = {}; 764 | 765 | if (xJson) { 766 | // Parse x-json attribute 767 | try { 768 | bodyData = JSON.parse(xJson); 769 | } catch (e) { 770 | console.error('Invalid JSON in x-json attribute:', e); 771 | throw new Error('Invalid JSON in x-json attribute'); 772 | } 773 | } else if (element && (element.tagName === 'FORM' || element.closest('form'))) { 774 | const form = element.tagName === 'FORM' ? element : element.closest('form'); 775 | if (serializeType === this.SERIALIZE_TYPES.JSON) { 776 | // Serialize form data as flat JSON 777 | bodyData = JSON.parse(Serializer.serializeFormToJson(form)); 778 | } else if (serializeType === this.SERIALIZE_TYPES.NESTED_JSON) { 779 | // Serialize form data as nested JSON 780 | bodyData = JSON.parse(Serializer.serializeFormToNestedJson(form)); 781 | } else if (serializeType === this.SERIALIZE_TYPES.XML) { 782 | // Serialize form data as XML 783 | bodyData = Serializer.serializeFormToXml(form); 784 | headers['Content-Type'] = 'application/xml'; 785 | } else { 786 | // Use FormData 787 | bodyData = new FormData(form); 788 | } 789 | } 790 | 791 | // Merge paramsObject with bodyData 792 | if (paramsObject && Object.keys(paramsObject).length > 0) { 793 | if (bodyData instanceof FormData) { 794 | // Append params to FormData 795 | for (const key in paramsObject) { 796 | bodyData.append(key, paramsObject[key]); 797 | } 798 | } else if (typeof bodyData === 'string') { 799 | // Parse existing bodyData and merge 800 | bodyData = { ...JSON.parse(bodyData), ...paramsObject }; 801 | } else { 802 | // Merge objects 803 | bodyData = { ...bodyData, ...paramsObject }; 804 | } 805 | } 806 | 807 | if (bodyData instanceof FormData) { 808 | options.body = bodyData; 809 | } else if (typeof bodyData === 'string') { 810 | options.body = bodyData; 811 | headers['Content-Type'] = headers['Content-Type'] || 'application/json'; 812 | } else { 813 | options.body = JSON.stringify(bodyData); 814 | headers['Content-Type'] = headers['Content-Type'] || 'application/json'; 815 | } 816 | } else { 817 | // For GET requests, append params to URL 818 | if (paramsObject && Object.keys(paramsObject).length > 0) { 819 | const urlParams = new URLSearchParams(paramsObject).toString(); 820 | requestUrl += (requestUrl.includes('?') ? '&' : '?') + urlParams; 821 | } 822 | } 823 | 824 | // Start the timeout if specified 825 | let timeoutId; 826 | if (!isNaN(timeout) && timeout > 0) { 827 | timeoutId = setTimeout(() => { 828 | controller.abort(); 829 | }, timeout); 830 | } 831 | 832 | let attempts = 0; 833 | const maxAttempts = maxRetries + 1; 834 | 835 | while (attempts < maxAttempts) { 836 | attempts++; 837 | try { 838 | const response = await fetch(requestUrl, options); 839 | if (timeoutId) { 840 | clearTimeout(timeoutId); 841 | } 842 | 843 | this.lastResponse = response; 844 | 845 | if (!response.ok) { 846 | const text = await response.text(); 847 | throw new Error(`HTTP error ${response.status}: ${text}`); 848 | } 849 | return response.text(); 850 | } catch (error) { 851 | if (timeoutId) { 852 | clearTimeout(timeoutId); 853 | } 854 | 855 | if (error.name === 'AbortError') { 856 | throw new Error('Request timed out'); 857 | } 858 | 859 | if (attempts >= maxAttempts) { 860 | throw error; 861 | } 862 | // TODO, implement a delay before retrying 863 | } 864 | } 865 | } 866 | 867 | // Response Processing Methods 868 | // --------------------------- 869 | 870 | /** 871 | * Processes the response text and updates the DOM accordingly. 872 | * @param {string} responseText 873 | * @param {HTMLElement} targetElement 874 | * @param {HTMLElement} element 875 | */ 876 | async processResponse(responseText, targetElement, element) { 877 | // Dispatch beforeUpdate event 878 | this.dispatchEvent('beforeUpdate', { targetElement, element }); 879 | 880 | // Parse the response HTML 881 | const parser = new DOMParser(); 882 | const doc = parser.parseFromString(responseText, 'text/html'); 883 | 884 | // Extract OOB elements 885 | const oobElements = Array.from(doc.querySelectorAll(`[${this.ATTRIBUTES.SWAP_OOB}]`)); 886 | oobElements.forEach(el => el.parentNode.removeChild(el)); 887 | 888 | // Handle backend instructions 889 | const backendTargetSelector = this.lastResponse.headers.get('X-Target'); 890 | const backendSwapOption = this.lastResponse.headers.get('X-Swap'); 891 | const infiniteScrollAction = this.lastResponse.headers.get('X-Infinite-Scroll'); 892 | 893 | // Determine the target element 894 | let finalTargetElement = targetElement; 895 | if (backendTargetSelector) { 896 | const backendTargetElement = document.querySelector(backendTargetSelector); 897 | if (backendTargetElement) { 898 | finalTargetElement = backendTargetElement; 899 | } else { 900 | console.error(`No element found with selector '${backendTargetSelector}' specified in X-Target header.`); 901 | } 902 | } 903 | 904 | // Determine the swap option 905 | let swapOption = this.getAttributeWithInheritance(element, this.ATTRIBUTES.SWAP) || this.defaultSwapOption; 906 | if (backendSwapOption) { 907 | swapOption = backendSwapOption; 908 | } 909 | 910 | // Get the content from the response 911 | const newContent = doc.body.innerHTML; 912 | 913 | // Replace the target's content 914 | this.performSwap(finalTargetElement, newContent, swapOption); 915 | 916 | // Dispatch afterUpdate event 917 | this.dispatchEvent('afterUpdate', { targetElement: finalTargetElement, element }); 918 | 919 | // Re-scan the newly added content for Partial elements 920 | this.scanForElements(finalTargetElement); 921 | 922 | // Handle OOB swapping with the extracted OOB elements 923 | this.handleOobSwapping(oobElements); 924 | 925 | // Handle any x-event-* headers from the response 926 | await this.handleResponseEvents(); 927 | 928 | // Stop infinite scroll if instructed by backend 929 | if (infiniteScrollAction === 'stop' && element.hasAttribute(this.ATTRIBUTES.INFINITE_SCROLL)) { 930 | this.stopInfiniteScroll(element); 931 | } 932 | } 933 | 934 | /** 935 | * Handles Out-of-Band (OOB) swapping by processing an array of OOB elements. 936 | * Replaces existing elements in the document with the new content based on matching IDs. 937 | * @param {HTMLElement[]} oobElements 938 | */ 939 | handleOobSwapping(oobElements) { 940 | oobElements.forEach(oobElement => { 941 | const targetId = oobElement.getAttribute('id'); 942 | if (!targetId) { 943 | console.error('OOB element does not have an ID:', oobElement); 944 | return; 945 | } 946 | 947 | const swapOption = oobElement.getAttribute(this.ATTRIBUTES.SWAP_OOB) || this.defaultSwapOption; 948 | const existingElement = document.getElementById(targetId); 949 | 950 | if (!existingElement) { 951 | console.error(`No existing element found with ID '${targetId}' for OOB swapping.`); 952 | return; 953 | } 954 | 955 | const newContent = oobElement.outerHTML; 956 | 957 | this.performSwap(existingElement, newContent, swapOption); 958 | 959 | // After swapping, initialize any new elements within the replaced content 960 | const newElement = document.getElementById(targetId); 961 | if (newElement) { 962 | this.scanForElements(newElement); 963 | } 964 | }); 965 | } 966 | 967 | /** 968 | * Performs the swap operation on the target element based on the swap option. 969 | * @param {HTMLElement} targetElement 970 | * @param {string} newContent 971 | * @param {string} swapOption 972 | */ 973 | performSwap(targetElement, newContent, swapOption) { 974 | switch (swapOption) { 975 | case 'innerHTML': 976 | targetElement.innerHTML = newContent; 977 | break; 978 | case 'outerHTML': 979 | targetElement.outerHTML = newContent; 980 | break; 981 | case 'beforebegin': 982 | case 'afterbegin': 983 | case 'beforeend': 984 | case 'afterend': 985 | targetElement.insertAdjacentHTML(swapOption, newContent); 986 | break; 987 | default: 988 | console.error(`Invalid swap option '${swapOption}'. Using 'innerHTML' as default.`); 989 | targetElement.innerHTML = newContent; 990 | break; 991 | } 992 | } 993 | 994 | /** 995 | * Handles any x-event-* headers from the response and dispatches events accordingly. 996 | */ 997 | async handleResponseEvents() { 998 | if (!this.lastResponse || !this.lastResponse.headers) { 999 | return; 1000 | } 1001 | 1002 | this.lastResponse.headers.forEach((value, name) => { 1003 | const lowerName = name.toLowerCase(); 1004 | if (lowerName.startsWith('x-event-')) { 1005 | const eventName = name.substring(8); // Remove 'x-event-' prefix 1006 | let eventData = value; 1007 | try { 1008 | eventData = JSON.parse(value); 1009 | } catch (e) { 1010 | // Value is not JSON, use as is 1011 | } 1012 | this.dispatchEvent(eventName, eventData); 1013 | } 1014 | }); 1015 | } 1016 | 1017 | // Event Handling Methods 1018 | // ---------------------- 1019 | 1020 | /** 1021 | * Dispatches custom events specified in a comma-separated string. 1022 | * @param {string} events - Comma-separated event names. 1023 | * @param {Object} detail - Detail object to pass with the event. 1024 | */ 1025 | async dispatchCustomEvents(events, detail) { 1026 | const eventNames = events.split(',').map(e => e.trim()); 1027 | for (const eventName of eventNames) { 1028 | const event = new CustomEvent(eventName, { detail }); 1029 | this.eventTarget.dispatchEvent(event); 1030 | } 1031 | } 1032 | 1033 | /** 1034 | * Handles the popstate event for browser navigation. 1035 | * @param {PopStateEvent} event 1036 | */ 1037 | async handlePopState(event) { 1038 | if (event.state && event.state.xPartial) { 1039 | const url = window.location.href; 1040 | try { 1041 | const responseText = await this.performRequest({ method: 'GET', url, headers: {}, element: null }); 1042 | 1043 | // Parse the response HTML 1044 | const parser = new DOMParser(); 1045 | const doc = parser.parseFromString(responseText, 'text/html'); 1046 | 1047 | // Replace the body content 1048 | document.body.innerHTML = doc.body.innerHTML; 1049 | 1050 | // Re-scan the entire document 1051 | this.scanForElements(); 1052 | 1053 | // Optionally, focus the body 1054 | if (this.autoFocus) { 1055 | document.body.focus(); 1056 | } 1057 | 1058 | } catch (error) { 1059 | this.handleError(error, document.body); 1060 | } 1061 | } 1062 | } 1063 | 1064 | /** 1065 | * Listens for a custom event and executes the callback when the event is dispatched. 1066 | * @param {string} eventName - The name of the event to listen for 1067 | * @param {Function} callback - The function to call when the event is dispatched 1068 | * @param {boolean | AddEventListenerOptions} [options] - Optional options for addEventListener. 1069 | */ 1070 | event(eventName, callback, options) { 1071 | if (!this.eventListeners[eventName]) { 1072 | this.eventListeners[eventName] = []; 1073 | } 1074 | this.eventListeners[eventName].push({ callback, options }); 1075 | this.eventTarget.addEventListener(eventName, callback, options); 1076 | } 1077 | 1078 | /** 1079 | * Removes a custom event listener. 1080 | * @param {string} eventName - The name of the event to remove 1081 | * @param {Function} callback - The function to remove 1082 | * @param {boolean | AddEventListenerOptions} [options] - Optional options for addEventListener. 1083 | */ 1084 | removeEvent(eventName, callback, options) { 1085 | if (this.eventListeners[eventName]) { 1086 | // Find the index of the listener to remove 1087 | const index = this.eventListeners[eventName].findIndex( 1088 | (listener) => listener.callback === callback && JSON.stringify(listener.options) === JSON.stringify(options) 1089 | ); 1090 | if (index !== -1) { 1091 | // Remove the listener from the registry 1092 | this.eventListeners[eventName].splice(index, 1); 1093 | // If no more listeners for this event, delete the event key 1094 | if (this.eventListeners[eventName].length === 0) { 1095 | delete this.eventListeners[eventName]; 1096 | } 1097 | } 1098 | } 1099 | 1100 | this.eventTarget.removeEventListener(eventName, callback, options); 1101 | } 1102 | 1103 | /** 1104 | * Removes all event listeners for the given event name. 1105 | * @param {string} eventName 1106 | */ 1107 | removeAllEvents(eventName) { 1108 | if (this.eventListeners[eventName]) { 1109 | this.eventListeners[eventName].forEach(({ callback, options }) => { 1110 | this.eventTarget.removeEventListener(eventName, callback, options); 1111 | }); 1112 | delete this.eventListeners[eventName]; 1113 | } 1114 | } 1115 | 1116 | /** 1117 | * Dispatches a custom event with the given name and data. 1118 | * @param {string} eventName 1119 | * @param {any} eventData 1120 | */ 1121 | dispatchEvent(eventName, eventData) { 1122 | const event = new CustomEvent(eventName, { detail: eventData }); 1123 | this.eventTarget.dispatchEvent(event); 1124 | } 1125 | 1126 | // Cleanup Methods 1127 | // --------------- 1128 | 1129 | /** 1130 | * Allows manually re-scanning a specific container for Partial elements. 1131 | * Useful when dynamically adding content to the DOM. 1132 | * @param {HTMLElement} container 1133 | */ 1134 | refresh(container = document) { 1135 | this.scanForElements(container); 1136 | } 1137 | 1138 | /** 1139 | * Clean up SSE connections when elements are removed. 1140 | * @param {HTMLElement} element 1141 | */ 1142 | cleanupSSEElement(element) { 1143 | if (this.sseConnections.has(element)) { 1144 | const eventSource = this.sseConnections.get(element); 1145 | eventSource.close(); 1146 | this.sseConnections.delete(element); 1147 | element.__xSSEInitialized = false; 1148 | } 1149 | } 1150 | 1151 | // Error Handling Methods 1152 | // ---------------------- 1153 | 1154 | /** 1155 | * Handles errors by calling the provided error callback or logging to the console. 1156 | * @param {Error} error 1157 | * @param {HTMLElement} element 1158 | * @param {HTMLElement} [targetElement] 1159 | */ 1160 | handleError(error, element, targetElement = null) { 1161 | if (typeof this.onError === 'function') { 1162 | this.onError(error, element); 1163 | } else { 1164 | console.error('Error:', error); 1165 | if (targetElement) { 1166 | targetElement.innerHTML = `
An error occurred: ${error.message}
`; 1167 | } 1168 | } 1169 | } 1170 | } 1171 | 1172 | class Serializer { 1173 | /** 1174 | * Serializes form data to a flat JSON string. 1175 | * @param {HTMLFormElement} form 1176 | * @returns {string} JSON string 1177 | */ 1178 | static serializeFormToJson(form) { 1179 | const formData = new FormData(form); 1180 | const jsonObject = {}; 1181 | formData.forEach((value, key) => { 1182 | if (jsonObject[key]) { 1183 | if (Array.isArray(jsonObject[key])) { 1184 | jsonObject[key].push(value); 1185 | } else { 1186 | jsonObject[key] = [jsonObject[key], value]; 1187 | } 1188 | } else { 1189 | jsonObject[key] = value; 1190 | } 1191 | }); 1192 | return JSON.stringify(jsonObject); 1193 | } 1194 | 1195 | /** 1196 | * Serializes form data to a nested JSON string. 1197 | * @param {HTMLFormElement} form 1198 | * @returns {string} Nested JSON string 1199 | */ 1200 | serializeFormToNestedJson(form) { 1201 | const formData = new FormData(form); 1202 | const serializedData = {}; 1203 | 1204 | for (let [name, value] of formData) { 1205 | const inputElement = form.querySelector(`[name="${name}"]`); 1206 | const checkBoxCustom = form.querySelector(`[data-custom="true"]`); 1207 | const inputType = inputElement ? inputElement.type : null; 1208 | const inputStep = inputElement ? inputElement.step : null; 1209 | 1210 | // Check if the input type is number and convert the value if so 1211 | if (inputType === 'number') { 1212 | if (inputStep && inputStep !== "any" && Number(inputStep) % 1 === 0) { 1213 | value = parseInt(value, 10); 1214 | } else if (inputStep === "any") { 1215 | value = value.includes('.') ? parseFloat(value) : parseInt(value, 10); 1216 | } else { 1217 | value = parseFloat(value); 1218 | } 1219 | } 1220 | 1221 | // Check if the input type is checkbox and convert the value to boolean 1222 | if (inputType === 'checkbox' && !checkBoxCustom) { 1223 | value = inputElement.checked; // value will be true if checked, false otherwise 1224 | } 1225 | 1226 | // Check if the input type is select-one and has data-bool attribute 1227 | if (inputType === 'select-one' && inputElement.getAttribute('data-bool') === 'true') { 1228 | value = value === "true"; // Value will be true if selected, false otherwise 1229 | } 1230 | 1231 | // Attempt to parse JSON strings 1232 | try { 1233 | value = JSON.parse(value); 1234 | } catch (e) { 1235 | // If parsing fails, treat as a simple string 1236 | } 1237 | 1238 | const keys = name.split(/[.[\]]+/).filter(Boolean); // split by dot or bracket notation 1239 | let obj = serializedData; 1240 | 1241 | for (let i = 0; i < keys.length - 1; i++) { 1242 | if (!obj[keys[i]]) { 1243 | obj[keys[i]] = /^\d+$/.test(keys[i + 1]) ? [] : {}; // create an array if the next key is an index 1244 | } 1245 | obj = obj[keys[i]]; 1246 | } 1247 | 1248 | const lastKey = keys[keys.length - 1]; 1249 | if (lastKey in obj && Array.isArray(obj[lastKey])) { 1250 | obj[lastKey].push(value); // add to array if the key already exists 1251 | } else if (lastKey in obj) { 1252 | obj[lastKey] = [obj[lastKey], value]; 1253 | } else { 1254 | obj[lastKey] = value; // set value for key 1255 | } 1256 | } 1257 | 1258 | return JSON.stringify(serializedData); 1259 | } 1260 | 1261 | /** 1262 | * Serializes form data to an XML string. 1263 | * @param {HTMLFormElement} form 1264 | * @returns {string} XML string 1265 | */ 1266 | static serializeFormToXml(form) { 1267 | const formData = new FormData(form); 1268 | let xmlString = '
'; 1269 | 1270 | formData.forEach((value, key) => { 1271 | xmlString += `<${key}>${this.escapeXml(value)}`; 1272 | }); 1273 | 1274 | xmlString += '
'; 1275 | return xmlString; 1276 | } 1277 | 1278 | /** 1279 | * Escapes XML special characters. 1280 | * @param {string} unsafe 1281 | * @returns {string} 1282 | */ 1283 | static escapeXml(unsafe) { 1284 | return unsafe.replace(/[<>&'"]/g, function (c) { 1285 | switch (c) { 1286 | case '<': return '<'; 1287 | case '>': return '>'; 1288 | case '&': return '&'; 1289 | case '\'': return '''; 1290 | case '"': return '"'; 1291 | default: return c; 1292 | } 1293 | }); 1294 | } 1295 | } 1296 | -------------------------------------------------------------------------------- /partial.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "html/template" 9 | "io/fs" 10 | "log/slog" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path" 15 | "reflect" 16 | "strings" 17 | "sync" 18 | 19 | "github.com/donseba/go-partial/connector" 20 | ) 21 | 22 | var ( 23 | // templateCache is the cache for parsed templates 24 | templateCache = sync.Map{} 25 | // mutexCache is a cache of mutexes for each template key 26 | mutexCache = sync.Map{} 27 | // protectedFunctionNames is a set of function names that are protected from being overridden 28 | protectedFunctionNames = map[string]struct{}{ 29 | "action": {}, 30 | "actionHeader": {}, 31 | "child": {}, 32 | "context": {}, 33 | "ifRequestedAction": {}, 34 | "ifRequestedPartial": {}, 35 | "ifRequestedSelect": {}, 36 | "ifSwapOOB": {}, 37 | "partialHeader": {}, 38 | "requestedPartial": {}, 39 | "requestedAction": {}, 40 | "requestedSelect": {}, 41 | "selectHeader": {}, 42 | "selection": {}, 43 | "swapOOB": {}, 44 | "url": {}, 45 | } 46 | ) 47 | 48 | type ( 49 | // Partial represents a renderable component with optional children and data. 50 | Partial struct { 51 | id string 52 | parent *Partial 53 | request *http.Request 54 | swapOOB bool 55 | fs fs.FS 56 | logger Logger 57 | connector connector.Connector 58 | useCache bool 59 | templates []string 60 | combinedFunctions template.FuncMap 61 | data map[string]any 62 | layoutData map[string]any 63 | globalData map[string]any 64 | responseHeaders map[string]string 65 | mu sync.RWMutex 66 | children map[string]*Partial 67 | oobChildren map[string]struct{} 68 | selection *Selection 69 | templateAction func(ctx context.Context, p *Partial, data *Data) (*Partial, error) 70 | action func(ctx context.Context, p *Partial, data *Data) (*Partial, error) 71 | } 72 | 73 | Selection struct { 74 | Partials map[string]*Partial 75 | Default string 76 | } 77 | 78 | // Data represents the data available to the partial. 79 | Data struct { 80 | // Ctx is the context of the request 81 | Ctx context.Context 82 | // URL is the URL of the request 83 | URL *url.URL 84 | // Request contains the http.Request 85 | Request *http.Request 86 | // Data contains the data specific to this partial 87 | Data map[string]any 88 | // Service contains global data available to all partials 89 | Service map[string]any 90 | // LayoutData contains data specific to the service 91 | Layout map[string]any 92 | } 93 | 94 | // GlobalData represents the global data available to all partials. 95 | GlobalData map[string]any 96 | ) 97 | 98 | // New creates a new root. 99 | func New(templates ...string) *Partial { 100 | return &Partial{ 101 | id: "root", 102 | templates: templates, 103 | combinedFunctions: make(template.FuncMap), 104 | data: make(map[string]any), 105 | layoutData: make(map[string]any), 106 | globalData: make(map[string]any), 107 | children: make(map[string]*Partial), 108 | oobChildren: make(map[string]struct{}), 109 | fs: os.DirFS("./"), 110 | } 111 | } 112 | 113 | // NewID creates a new instance with the provided ID. 114 | func NewID(id string, templates ...string) *Partial { 115 | return New(templates...).ID(id) 116 | } 117 | 118 | // ID sets the ID of the partial. 119 | func (p *Partial) ID(id string) *Partial { 120 | p.id = id 121 | return p 122 | } 123 | 124 | // Templates sets the templates for the partial. 125 | func (p *Partial) Templates(templates ...string) *Partial { 126 | p.templates = templates 127 | return p 128 | } 129 | 130 | // Reset resets the partial to its initial state. 131 | func (p *Partial) Reset() *Partial { 132 | p.data = make(map[string]any) 133 | p.layoutData = make(map[string]any) 134 | p.globalData = make(map[string]any) 135 | p.children = make(map[string]*Partial) 136 | p.oobChildren = make(map[string]struct{}) 137 | 138 | return p 139 | } 140 | 141 | // SetData sets the data for the partial. 142 | func (p *Partial) SetData(data map[string]any) *Partial { 143 | p.data = data 144 | return p 145 | } 146 | 147 | // AddData adds data to the partial. 148 | func (p *Partial) AddData(key string, value any) *Partial { 149 | p.data[key] = value 150 | return p 151 | } 152 | 153 | func (p *Partial) SetResponseHeaders(headers map[string]string) *Partial { 154 | p.responseHeaders = headers 155 | return p 156 | } 157 | 158 | func (p *Partial) GetResponseHeaders() map[string]string { 159 | if p == nil { 160 | return nil 161 | } 162 | 163 | if p.responseHeaders == nil { 164 | return p.parent.GetResponseHeaders() 165 | } 166 | 167 | return p.responseHeaders 168 | } 169 | 170 | // SetConnector sets the connector for the partial. 171 | func (p *Partial) SetConnector(connector connector.Connector) *Partial { 172 | p.connector = connector 173 | return p 174 | } 175 | 176 | // MergeData merges the data into the partial. 177 | func (p *Partial) MergeData(data map[string]any, override bool) *Partial { 178 | for k, v := range data { 179 | if _, ok := p.data[k]; ok && !override { 180 | continue 181 | } 182 | 183 | p.data[k] = v 184 | } 185 | return p 186 | } 187 | 188 | // AddFunc adds a function to the partial. 189 | func (p *Partial) AddFunc(name string, fn interface{}) *Partial { 190 | if _, ok := protectedFunctionNames[name]; ok { 191 | p.getLogger().Warn("function name is protected and cannot be overwritten", "function", name) 192 | return p 193 | } 194 | 195 | p.mu.Lock() 196 | p.combinedFunctions[name] = fn 197 | p.mu.Unlock() 198 | 199 | return p 200 | } 201 | 202 | // MergeFuncMap merges the given FuncMap with the existing FuncMap in the Partial. 203 | func (p *Partial) MergeFuncMap(funcMap template.FuncMap) { 204 | p.mu.Lock() 205 | defer p.mu.Unlock() 206 | 207 | for k, v := range funcMap { 208 | if _, ok := protectedFunctionNames[k]; ok { 209 | p.getLogger().Warn("function name is protected and cannot be overwritten", "function", k) 210 | continue 211 | } 212 | 213 | p.combinedFunctions[k] = v 214 | } 215 | } 216 | 217 | // SetLogger sets the logger for the partial. 218 | func (p *Partial) SetLogger(logger Logger) *Partial { 219 | p.logger = logger 220 | return p 221 | } 222 | 223 | // SetFileSystem sets the file system for the partial. 224 | func (p *Partial) SetFileSystem(fs fs.FS) *Partial { 225 | p.fs = fs 226 | return p 227 | } 228 | 229 | // UseCache sets the cache usage flag for the partial. 230 | func (p *Partial) UseCache(useCache bool) *Partial { 231 | p.useCache = useCache 232 | return p 233 | } 234 | 235 | // SetGlobalData sets the global data for the partial. 236 | func (p *Partial) SetGlobalData(data map[string]any) *Partial { 237 | p.globalData = data 238 | return p 239 | } 240 | 241 | // SetLayoutData sets the layout data for the partial. 242 | func (p *Partial) SetLayoutData(data map[string]any) *Partial { 243 | p.layoutData = data 244 | return p 245 | } 246 | 247 | // AddTemplate adds a template to the partial. 248 | func (p *Partial) AddTemplate(template string) *Partial { 249 | p.templates = append(p.templates, template) 250 | return p 251 | } 252 | 253 | // With adds a child partial to the partial. 254 | func (p *Partial) With(child *Partial) *Partial { 255 | p.mu.Lock() 256 | defer p.mu.Unlock() 257 | 258 | p.children[child.id] = child 259 | p.children[child.id].globalData = p.globalData 260 | p.children[child.id].parent = p 261 | 262 | return p 263 | } 264 | 265 | // WithAction adds callback action to the partial, which can do some logic and return a partial to render. 266 | func (p *Partial) WithAction(action func(ctx context.Context, p *Partial, data *Data) (*Partial, error)) *Partial { 267 | p.action = action 268 | return p 269 | } 270 | 271 | func (p *Partial) WithTemplateAction(templateAction func(ctx context.Context, p *Partial, data *Data) (*Partial, error)) *Partial { 272 | p.templateAction = templateAction 273 | return p 274 | } 275 | 276 | // WithSelectMap adds a selection partial to the partial. 277 | func (p *Partial) WithSelectMap(defaultKey string, partialsMap map[string]*Partial) *Partial { 278 | p.mu.Lock() 279 | defer p.mu.Unlock() 280 | 281 | p.selection = &Selection{ 282 | Default: defaultKey, 283 | Partials: partialsMap, 284 | } 285 | 286 | return p 287 | } 288 | 289 | // SetParent sets the parent of the partial. 290 | func (p *Partial) SetParent(parent *Partial) *Partial { 291 | p.parent = parent 292 | return p 293 | } 294 | 295 | // WithOOB adds an out-of-band child partial to the partial. 296 | func (p *Partial) WithOOB(child *Partial) *Partial { 297 | p.With(child) 298 | p.mu.Lock() 299 | p.oobChildren[child.id] = struct{}{} 300 | p.mu.Unlock() 301 | 302 | return p 303 | } 304 | 305 | // RenderWithRequest renders the partial with the given http.Request. 306 | func (p *Partial) RenderWithRequest(ctx context.Context, r *http.Request) (template.HTML, error) { 307 | if p == nil { 308 | return "", errors.New("partial is not initialized") 309 | } 310 | 311 | p.request = r 312 | if p.connector == nil { 313 | p.connector = connector.NewPartial(nil) 314 | } 315 | 316 | if p.connector.RenderPartial(r) { 317 | return p.renderWithTarget(ctx, r) 318 | } 319 | 320 | return p.renderSelf(ctx, r) 321 | } 322 | 323 | // WriteWithRequest writes the partial to the http.ResponseWriter. 324 | func (p *Partial) WriteWithRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error { 325 | if p == nil { 326 | _, err := fmt.Fprintf(w, "partial is not initialized") 327 | return err 328 | } 329 | 330 | out, err := p.RenderWithRequest(ctx, r) 331 | if err != nil { 332 | p.getLogger().Error("error rendering partial", "error", err) 333 | return err 334 | } 335 | 336 | // get headers 337 | headers := p.GetResponseHeaders() 338 | for k, v := range headers { 339 | w.Header().Set(k, v) 340 | } 341 | 342 | _, err = w.Write([]byte(out)) 343 | if err != nil { 344 | p.getLogger().Error("error writing partial to response", "error", err) 345 | return err 346 | } 347 | 348 | return nil 349 | } 350 | 351 | // Render renders the partial without requiring an http.Request. 352 | // It can be used when you don't need access to the request data. 353 | func (p *Partial) Render(ctx context.Context) (template.HTML, error) { 354 | if p == nil { 355 | return "", errors.New("partial is not initialized") 356 | } 357 | 358 | // Since we don't have an http.Request, we'll pass nil where appropriate. 359 | return p.renderSelf(ctx, nil) 360 | } 361 | 362 | func (p *Partial) mergeFuncMapInternal(funcMap template.FuncMap) { 363 | p.mu.Lock() 364 | defer p.mu.Unlock() 365 | 366 | for k, v := range funcMap { 367 | p.combinedFunctions[k] = v 368 | } 369 | } 370 | 371 | // getFuncMap returns the combined function map of the partial. 372 | func (p *Partial) getFuncMap() template.FuncMap { 373 | p.mu.RLock() 374 | defer p.mu.RUnlock() 375 | 376 | if p.parent != nil { 377 | for k, v := range p.parent.getFuncMap() { 378 | p.combinedFunctions[k] = v 379 | } 380 | 381 | return p.combinedFunctions 382 | } 383 | 384 | return p.combinedFunctions 385 | } 386 | 387 | func (p *Partial) getFuncs(data *Data) template.FuncMap { 388 | funcs := p.getFuncMap() 389 | 390 | funcs["child"] = childFunc(p, data) 391 | funcs["selection"] = selectionFunc(p, data) 392 | funcs["action"] = actionFunc(p, data) 393 | 394 | funcs["url"] = func() *url.URL { 395 | return data.URL 396 | } 397 | 398 | funcs["context"] = func() context.Context { 399 | return data.Ctx 400 | } 401 | 402 | funcs["partialHeader"] = func() string { 403 | return p.getConnector().GetTargetHeader() 404 | } 405 | 406 | funcs["requestedPartial"] = func() string { 407 | return p.getConnector().GetTargetValue(p.GetRequest()) 408 | } 409 | 410 | funcs["ifRequestedPartial"] = func(out any, in ...string) any { 411 | target := p.getConnector().GetTargetValue(p.GetRequest()) 412 | for _, v := range in { 413 | if v == target { 414 | return out 415 | } 416 | } 417 | return nil 418 | } 419 | 420 | funcs["selectHeader"] = func() string { 421 | return p.getConnector().GetSelectHeader() 422 | } 423 | 424 | funcs["requestedSelect"] = func() string { 425 | requestedSelect := p.getConnector().GetSelectValue(p.GetRequest()) 426 | 427 | if requestedSelect == "" { 428 | return p.selection.Default 429 | } 430 | return requestedSelect 431 | } 432 | 433 | funcs["ifRequestedSelect"] = func(out any, in ...string) any { 434 | selected := p.getConnector().GetSelectValue(p.GetRequest()) 435 | for _, v := range in { 436 | if v == selected { 437 | return out 438 | } 439 | } 440 | return nil 441 | } 442 | 443 | funcs["actionHeader"] = func() string { 444 | return p.getConnector().GetActionHeader() 445 | } 446 | 447 | funcs["requestedAction"] = func() string { 448 | return p.getConnector().GetActionValue(p.GetRequest()) 449 | } 450 | 451 | funcs["ifRequestedAction"] = func(out any, in ...string) any { 452 | action := p.getConnector().GetActionValue(p.GetRequest()) 453 | for _, v := range in { 454 | if v == action { 455 | return out 456 | } 457 | } 458 | return nil 459 | } 460 | 461 | funcs["swapOOB"] = func() bool { 462 | return p.swapOOB 463 | } 464 | 465 | funcs["ifSwapOOB"] = func(v string) template.HTML { 466 | if p.swapOOB { 467 | return template.HTML("x-swap-oob=\" + v + \"") 468 | } 469 | // Return an empty trusted HTML instead of a plain empty string 470 | return template.HTML("") 471 | } 472 | 473 | return funcs 474 | } 475 | 476 | func (p *Partial) getGlobalData() map[string]any { 477 | if p.parent != nil { 478 | globalData := p.parent.getGlobalData() 479 | for k, v := range p.globalData { 480 | globalData[k] = v 481 | } 482 | return globalData 483 | } 484 | return p.globalData 485 | } 486 | 487 | func (p *Partial) getLayoutData() map[string]any { 488 | if p.parent != nil { 489 | layoutData := p.parent.getLayoutData() 490 | for k, v := range p.layoutData { 491 | layoutData[k] = v 492 | } 493 | return layoutData 494 | } 495 | return p.layoutData 496 | } 497 | 498 | func (p *Partial) getConnector() connector.Connector { 499 | if p.connector != nil { 500 | return p.connector 501 | } 502 | if p.parent != nil { 503 | return p.parent.getConnector() 504 | } 505 | return nil 506 | } 507 | 508 | func (p *Partial) getSelectionPartials() map[string]*Partial { 509 | if p.selection != nil { 510 | return p.selection.Partials 511 | } 512 | return nil 513 | } 514 | 515 | func (p *Partial) GetRequest() *http.Request { 516 | if p.request != nil { 517 | return p.request 518 | } 519 | if p.parent != nil { 520 | return p.parent.GetRequest() 521 | } 522 | return &http.Request{} 523 | } 524 | 525 | func (p *Partial) getFS() fs.FS { 526 | if p.fs != nil { 527 | return p.fs 528 | } 529 | if p.parent != nil { 530 | return p.parent.getFS() 531 | } 532 | return os.DirFS("./") 533 | } 534 | 535 | func (p *Partial) getLogger() Logger { 536 | if p == nil { 537 | return slog.Default().WithGroup("partial") 538 | } 539 | 540 | if p.logger != nil { 541 | return p.logger 542 | } 543 | 544 | if p.parent != nil { 545 | return p.parent.getLogger() 546 | } 547 | 548 | // Cache the default logger in p.logger 549 | p.logger = slog.Default().WithGroup("partial") 550 | 551 | return p.logger 552 | } 553 | 554 | func (p *Partial) GetRequestedPartial() string { 555 | th := p.getConnector().GetTargetValue(p.GetRequest()) 556 | if th != "" { 557 | return th 558 | } 559 | if p.parent != nil { 560 | return p.parent.GetRequestedPartial() 561 | } 562 | return "" 563 | } 564 | 565 | func (p *Partial) GetRequestedAction() string { 566 | ah := p.getConnector().GetActionValue(p.GetRequest()) 567 | if ah != "" { 568 | return ah 569 | } 570 | if p.parent != nil { 571 | return p.parent.GetRequestedAction() 572 | } 573 | return "" 574 | } 575 | 576 | func (p *Partial) GetRequestedSelect() string { 577 | as := p.getConnector().GetSelectValue(p.GetRequest()) 578 | if as != "" { 579 | return as 580 | } 581 | if p.parent != nil { 582 | return p.parent.GetRequestedSelect() 583 | } 584 | return "" 585 | } 586 | 587 | func (p *Partial) renderWithTarget(ctx context.Context, r *http.Request) (template.HTML, error) { 588 | requestedTarget := p.getConnector().GetTargetValue(p.GetRequest()) 589 | if requestedTarget == "" || requestedTarget == p.id { 590 | out, err := p.renderSelf(ctx, r) 591 | if err != nil { 592 | return "", err 593 | } 594 | 595 | // Render OOB children of parent if necessary 596 | if p.parent != nil { 597 | oobOut, oobErr := p.parent.renderOOBChildren(ctx, r, true) 598 | if oobErr != nil { 599 | p.getLogger().Error("error rendering OOB children of parent", "error", oobErr, "parent", p.parent.id) 600 | return "", fmt.Errorf("error rendering OOB children of parent with ID '%s': %w", p.parent.id, oobErr) 601 | } 602 | out += oobOut 603 | } 604 | return out, nil 605 | } else { 606 | c := p.recursiveChildLookup(requestedTarget, make(map[string]bool)) 607 | if c == nil { 608 | p.getLogger().Error("requested partial not found in parent", "id", requestedTarget, "parent", p.id) 609 | return "", fmt.Errorf("requested partial %s not found in parent %s", requestedTarget, p.id) 610 | } 611 | return c.renderWithTarget(ctx, r) 612 | } 613 | } 614 | 615 | // recursiveChildLookup looks up a child recursively. 616 | func (p *Partial) recursiveChildLookup(id string, visited map[string]bool) *Partial { 617 | p.mu.RLock() 618 | defer p.mu.RUnlock() 619 | 620 | if visited[p.id] { 621 | return nil 622 | } 623 | visited[p.id] = true 624 | 625 | if c, ok := p.children[id]; ok { 626 | return c 627 | } 628 | 629 | for _, child := range p.children { 630 | if c := child.recursiveChildLookup(id, visited); c != nil { 631 | return c 632 | } 633 | } 634 | 635 | return nil 636 | } 637 | 638 | func (p *Partial) renderChildPartial(ctx context.Context, id string, data map[string]any) (template.HTML, error) { 639 | p.mu.RLock() 640 | child, ok := p.children[id] 641 | p.mu.RUnlock() 642 | if !ok { 643 | p.getLogger().Warn("child partial not found", "id", id) 644 | return "", nil 645 | } 646 | 647 | // Clone the child partial to avoid modifying the original and prevent data races 648 | childClone := child.clone() 649 | 650 | // Set the parent of the cloned child to the current partial 651 | childClone.parent = p 652 | 653 | // If additional data is provided, set it on the cloned child partial 654 | if data != nil { 655 | childClone.MergeData(data, true) 656 | } 657 | 658 | // Render the cloned child partial 659 | return childClone.renderSelf(ctx, p.GetRequest()) 660 | } 661 | 662 | // renderNamed renders the partial with the given name and templates. 663 | func (p *Partial) renderSelf(ctx context.Context, r *http.Request) (template.HTML, error) { 664 | if len(p.templates) == 0 { 665 | p.getLogger().Error("no templates provided for rendering") 666 | return "", errors.New("no templates provided for rendering") 667 | } 668 | 669 | var currentURL *url.URL 670 | if r != nil { 671 | currentURL = r.URL 672 | } 673 | 674 | data := &Data{ 675 | URL: currentURL, 676 | Request: r, 677 | Ctx: ctx, 678 | Data: p.data, 679 | Service: p.getGlobalData(), 680 | Layout: p.getLayoutData(), 681 | } 682 | 683 | if p.action != nil { 684 | var err error 685 | p, err = p.action(ctx, p, data) 686 | if err != nil { 687 | p.getLogger().Error("error in action function", "error", err) 688 | return "", fmt.Errorf("error in action function: %w", err) 689 | } 690 | } 691 | 692 | functions := p.getFuncs(data) 693 | funcMapPtr := reflect.ValueOf(functions).Pointer() 694 | 695 | cacheKey := p.generateCacheKey(p.templates, funcMapPtr) 696 | tmpl, err := p.getOrParseTemplate(cacheKey, functions) 697 | if err != nil { 698 | p.getLogger().Error("error getting or parsing template", "error", err) 699 | return "", err 700 | } 701 | 702 | var buf bytes.Buffer 703 | if err = tmpl.Execute(&buf, data); err != nil { 704 | p.getLogger().Error("error executing template", "template", p.templates[0], "error", err) 705 | return "", fmt.Errorf("error executing template '%s': %w", p.templates[0], err) 706 | } 707 | 708 | return template.HTML(buf.String()), nil 709 | } 710 | 711 | func (p *Partial) renderOOBChildren(ctx context.Context, r *http.Request, swapOOB bool) (template.HTML, error) { 712 | var out template.HTML 713 | p.mu.RLock() 714 | defer p.mu.RUnlock() 715 | 716 | for id := range p.oobChildren { 717 | if child, ok := p.children[id]; ok { 718 | child.swapOOB = swapOOB 719 | childData, err := child.renderSelf(ctx, r) 720 | if err != nil { 721 | return "", fmt.Errorf("error rendering OOB child '%s': %w", id, err) 722 | } 723 | out += childData 724 | } 725 | } 726 | return out, nil 727 | } 728 | 729 | func (p *Partial) getOrParseTemplate(cacheKey string, functions template.FuncMap) (*template.Template, error) { 730 | if tmpl, cached := templateCache.Load(cacheKey); cached && p.useCache { 731 | if t, ok := tmpl.(*template.Template); ok { 732 | return t, nil 733 | } 734 | } 735 | 736 | muInterface, _ := mutexCache.LoadOrStore(cacheKey, &sync.Mutex{}) 737 | mu := muInterface.(*sync.Mutex) 738 | mu.Lock() 739 | defer mu.Unlock() 740 | 741 | // Double-check after acquiring lock 742 | if tmpl, cached := templateCache.Load(cacheKey); cached && p.useCache { 743 | if t, ok := tmpl.(*template.Template); ok { 744 | return t, nil 745 | } 746 | } 747 | 748 | t := template.New(path.Base(p.templates[0])).Funcs(functions) 749 | tmpl, err := t.ParseFS(p.getFS(), p.templates...) 750 | if err != nil { 751 | return nil, fmt.Errorf("error parsing templates: %w", err) 752 | } 753 | 754 | if p.useCache { 755 | templateCache.Store(cacheKey, tmpl) 756 | } 757 | 758 | return tmpl, nil 759 | } 760 | 761 | func (p *Partial) clone() *Partial { 762 | p.mu.RLock() 763 | defer p.mu.RUnlock() 764 | 765 | // Create a new Partial instance 766 | clone := &Partial{ 767 | id: p.id, 768 | parent: p.parent, 769 | request: p.request, 770 | swapOOB: p.swapOOB, 771 | fs: p.fs, 772 | logger: p.logger, 773 | connector: p.connector, 774 | useCache: p.useCache, 775 | selection: p.selection, 776 | templates: append([]string{}, p.templates...), // Copy the slice 777 | combinedFunctions: make(template.FuncMap), 778 | data: make(map[string]any), 779 | layoutData: make(map[string]any), 780 | globalData: make(map[string]any), 781 | children: make(map[string]*Partial), 782 | oobChildren: make(map[string]struct{}), 783 | } 784 | 785 | // Copy the maps 786 | for k, v := range p.combinedFunctions { 787 | clone.combinedFunctions[k] = v 788 | } 789 | 790 | for k, v := range p.data { 791 | clone.data[k] = v 792 | } 793 | 794 | for k, v := range p.layoutData { 795 | clone.layoutData[k] = v 796 | } 797 | 798 | for k, v := range p.globalData { 799 | clone.globalData[k] = v 800 | } 801 | 802 | // Copy the children map 803 | for k, v := range p.children { 804 | clone.children[k] = v 805 | } 806 | 807 | // Copy the out-of-band children set 808 | for k, v := range p.oobChildren { 809 | clone.oobChildren[k] = v 810 | } 811 | 812 | return clone 813 | } 814 | 815 | // Generate a hash of the function names to include in the cache key 816 | func (p *Partial) generateCacheKey(templates []string, funcMapPtr uintptr) string { 817 | var builder strings.Builder 818 | 819 | // Include all template names 820 | for _, tmpl := range templates { 821 | builder.WriteString(tmpl) 822 | builder.WriteString(";") 823 | } 824 | 825 | // Include function map pointer 826 | builder.WriteString(fmt.Sprintf("funcMap:%x", funcMapPtr)) 827 | 828 | return builder.String() 829 | } 830 | -------------------------------------------------------------------------------- /partial_test.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/donseba/go-partial/connector" 13 | ) 14 | 15 | func TestNewRoot(t *testing.T) { 16 | root := New().Templates("template.gohtml") 17 | 18 | if root == nil { 19 | t.Error("NewRoot should not return nil") 20 | return 21 | } 22 | 23 | if root.id != "root" { 24 | t.Errorf("NewRoot should have id 'root', got %s", root.id) 25 | } 26 | 27 | if len(root.templates) != 1 { 28 | t.Errorf("NewRoot should have 1 template, got %d", len(root.templates)) 29 | } 30 | 31 | if root.templates[0] != "template.gohtml" { 32 | t.Errorf("NewRoot should have template 'template.gohtml', got %s", root.templates[0]) 33 | } 34 | 35 | if root.globalData == nil { 36 | t.Error("NewRoot should have non-nil globalData") 37 | } 38 | 39 | if len(root.children) != 0 { 40 | t.Errorf("NewRoot should have 0 children, got %d", len(root.children)) 41 | } 42 | 43 | if len(root.oobChildren) != 0 { 44 | t.Errorf("NewRoot should have 0 oobChildren, got %d", len(root.oobChildren)) 45 | } 46 | 47 | if root.data == nil { 48 | t.Error("NewRoot should have non-nil data") 49 | } 50 | 51 | if len(root.data) != 0 { 52 | t.Errorf("NewRoot should have 0 data, got %d", len(root.data)) 53 | } 54 | 55 | if root.Reset() != root { 56 | t.Error("Reset should return the partial") 57 | } 58 | } 59 | 60 | func TestRequestBasic(t *testing.T) { 61 | svc := NewService(&Config{}) 62 | 63 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 64 | fsys := &InMemoryFS{ 65 | Files: map[string]string{ 66 | "templates/index.html": `{{ child "content" }}`, 67 | "templates/content.html": "
{{.Data.Text}}
", 68 | }, 69 | } 70 | 71 | p := New("templates/index.html").ID("root") 72 | 73 | // content 74 | content := New("templates/content.html").ID("content") 75 | content.SetData(map[string]any{ 76 | "Text": "Welcome to the home page", 77 | }) 78 | p.With(content) 79 | 80 | out, err := svc.NewLayout().FS(fsys).Set(p).RenderWithRequest(r.Context(), r) 81 | if err != nil { 82 | _, _ = w.Write([]byte(err.Error())) 83 | return 84 | } 85 | 86 | _, _ = w.Write([]byte(out)) 87 | } 88 | 89 | t.Run("basic", func(t *testing.T) { 90 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 91 | response := httptest.NewRecorder() 92 | 93 | handleRequest(response, request) 94 | 95 | expected := "
Welcome to the home page
" 96 | if response.Body.String() != expected { 97 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 98 | } 99 | }) 100 | 101 | t.Run("partial", func(t *testing.T) { 102 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 103 | request.Header.Set("X-Target", "content") 104 | response := httptest.NewRecorder() 105 | 106 | handleRequest(response, request) 107 | 108 | expected := "
Welcome to the home page
" 109 | if response.Body.String() != expected { 110 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 111 | } 112 | }) 113 | } 114 | 115 | func TestRequestWrap(t *testing.T) { 116 | svc := NewService(&Config{}) 117 | 118 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 119 | fsys := &InMemoryFS{ 120 | Files: map[string]string{ 121 | "templates/index.html": `{{ child "content" }}`, 122 | "templates/content.html": "
{{.Data.Text}}
", 123 | }, 124 | } 125 | 126 | index := New("templates/index.html").ID("root") 127 | 128 | // content 129 | content := New("templates/content.html").ID("content") 130 | content.SetData(map[string]any{ 131 | "Text": "Welcome to the home page", 132 | }) 133 | 134 | out, err := svc.NewLayout().FS(fsys).Set(content).Wrap(index).RenderWithRequest(r.Context(), r) 135 | if err != nil { 136 | _, _ = w.Write([]byte(err.Error())) 137 | return 138 | } 139 | 140 | _, _ = w.Write([]byte(out)) 141 | } 142 | 143 | t.Run("basic", func(t *testing.T) { 144 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 145 | response := httptest.NewRecorder() 146 | 147 | handleRequest(response, request) 148 | 149 | expected := "
Welcome to the home page
" 150 | if response.Body.String() != expected { 151 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 152 | } 153 | }) 154 | 155 | t.Run("partial", func(t *testing.T) { 156 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 157 | request.Header.Set("X-Target", "content") 158 | response := httptest.NewRecorder() 159 | 160 | handleRequest(response, request) 161 | 162 | expected := "
Welcome to the home page
" 163 | if response.Body.String() != expected { 164 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 165 | } 166 | }) 167 | } 168 | 169 | func TestRequestOOB(t *testing.T) { 170 | svc := NewService(&Config{}) 171 | 172 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 173 | fsys := &InMemoryFS{ 174 | Files: map[string]string{ 175 | "templates/index.html": `{{ child "content" }}{{ child "footer" }}`, 176 | "templates/content.html": "
{{.Data.Text}}
", 177 | "templates/footer.html": "
{{.Data.Text}}
", 178 | }, 179 | } 180 | 181 | p := New("templates/index.html").ID("root") 182 | 183 | // content 184 | content := New("templates/content.html").ID("content") 185 | content.SetData(map[string]any{ 186 | "Text": "Welcome to the home page", 187 | }) 188 | p.With(content) 189 | 190 | // oob 191 | oob := New("templates/footer.html").ID("footer") 192 | oob.SetData(map[string]any{ 193 | "Text": "This is the footer", 194 | }) 195 | p.WithOOB(oob) 196 | 197 | out, err := svc.NewLayout().FS(fsys).Set(p).RenderWithRequest(r.Context(), r) 198 | if err != nil { 199 | _, _ = w.Write([]byte(err.Error())) 200 | return 201 | } 202 | 203 | _, _ = w.Write([]byte(out)) 204 | } 205 | 206 | t.Run("basic", func(t *testing.T) { 207 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 208 | response := httptest.NewRecorder() 209 | 210 | handleRequest(response, request) 211 | 212 | expected := "
Welcome to the home page
" 213 | if response.Body.String() != expected { 214 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 215 | } 216 | }) 217 | 218 | t.Run("partial", func(t *testing.T) { 219 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 220 | request.Header.Set("X-Target", "content") 221 | response := httptest.NewRecorder() 222 | 223 | handleRequest(response, request) 224 | 225 | expected := "
Welcome to the home page
" 226 | if response.Body.String() != expected { 227 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 228 | } 229 | }) 230 | } 231 | 232 | func TestRequestOOBSwap(t *testing.T) { 233 | svc := NewService(&Config{}) 234 | 235 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 236 | fsys := &InMemoryFS{ 237 | Files: map[string]string{ 238 | "templates/index.html": `{{ child "content" }}{{ child "footer" }}`, 239 | "templates/content.html": "
{{.Data.Text}}
", 240 | "templates/footer.html": "
{{.Data.Text}}
", 241 | }, 242 | } 243 | 244 | // the main template that will be rendered 245 | p := New("templates/index.html").ID("root") 246 | 247 | // oob footer that resides on the page 248 | oob := New("templates/footer.html").ID("footer") 249 | oob.SetData(map[string]any{ 250 | "Text": "This is the footer", 251 | }) 252 | p.WithOOB(oob) 253 | 254 | // the actual content required for the page 255 | content := New("templates/content.html").ID("content") 256 | content.SetData(map[string]any{ 257 | "Text": "Welcome to the home page", 258 | }) 259 | 260 | out, err := svc.NewLayout().FS(fsys).Set(content).Wrap(p).RenderWithRequest(r.Context(), r) 261 | if err != nil { 262 | _, _ = w.Write([]byte(err.Error())) 263 | return 264 | } 265 | 266 | _, _ = w.Write([]byte(out)) 267 | } 268 | 269 | t.Run("basic", func(t *testing.T) { 270 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 271 | response := httptest.NewRecorder() 272 | 273 | handleRequest(response, request) 274 | 275 | expected := "
Welcome to the home page
" 276 | if response.Body.String() != expected { 277 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 278 | } 279 | }) 280 | 281 | t.Run("partial", func(t *testing.T) { 282 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 283 | request.Header.Set("X-Target", "content") 284 | response := httptest.NewRecorder() 285 | 286 | handleRequest(response, request) 287 | 288 | expected := "
Welcome to the home page
" 289 | if response.Body.String() != expected { 290 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 291 | } 292 | }) 293 | } 294 | 295 | func TestDeepNested(t *testing.T) { 296 | svc := NewService(&Config{}) 297 | 298 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 299 | fsys := &InMemoryFS{ 300 | Files: map[string]string{ 301 | "templates/index.html": `{{ child "content" }}`, 302 | "templates/content.html": "
{{.Data.Text}}
", 303 | "templates/nested.html": `
{{ upper .Data.Text }}
`, 304 | }, 305 | } 306 | 307 | p := New("templates/index.html").ID("root") 308 | 309 | // nested content 310 | nested := New("templates/nested.html").ID("nested") 311 | nested.SetData(map[string]any{ 312 | "Text": "This is the nested content", 313 | }) 314 | 315 | // content 316 | content := New("templates/content.html").ID("content") 317 | content.SetData(map[string]any{ 318 | "Text": "Welcome to the home page", 319 | }).With(nested) 320 | 321 | p.With(content) 322 | 323 | out, err := svc.NewLayout().FS(fsys).Set(p).RenderWithRequest(r.Context(), r) 324 | if err != nil { 325 | _, _ = w.Write([]byte(err.Error())) 326 | return 327 | } 328 | 329 | _, _ = w.Write([]byte(out)) 330 | } 331 | 332 | t.Run("find nested item and return it", func(t *testing.T) { 333 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 334 | request.Header.Set("X-Target", "nested") 335 | response := httptest.NewRecorder() 336 | 337 | handleRequest(response, request) 338 | 339 | expected := "
THIS IS THE NESTED CONTENT
" 340 | if response.Body.String() != expected { 341 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 342 | } 343 | }) 344 | } 345 | 346 | func TestMissingPartial(t *testing.T) { 347 | svc := NewService(&Config{}) 348 | 349 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 350 | fsys := &InMemoryFS{ 351 | Files: map[string]string{ 352 | "templates/index.html": `{{ child "content" }}`, 353 | }, 354 | } 355 | 356 | p := New("templates/index.html").ID("root") 357 | 358 | out, err := svc.NewLayout().FS(fsys).Set(p).RenderWithRequest(r.Context(), r) 359 | if err != nil { 360 | http.Error(w, err.Error(), http.StatusInternalServerError) 361 | return 362 | } 363 | _, _ = w.Write([]byte(out)) 364 | } 365 | 366 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 367 | request.Header.Set("X-Target", "nonexistent") 368 | response := httptest.NewRecorder() 369 | 370 | handleRequest(response, request) 371 | 372 | if response.Code != http.StatusInternalServerError { 373 | t.Errorf("Expected status 500, got %d", response.Code) 374 | } 375 | } 376 | 377 | func TestDataInTemplates(t *testing.T) { 378 | svc := NewService(&Config{}) 379 | svc.AddData("Title", "My Page") 380 | 381 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 382 | // Create a new layout 383 | layout := svc.NewLayout() 384 | 385 | // Set LayoutData 386 | layout.SetData(map[string]any{ 387 | "PageTitle": "Home Page", 388 | "User": "John Doe", 389 | }) 390 | 391 | fsys := &InMemoryFS{ 392 | Files: map[string]string{ 393 | "templates/index.html": `{{ .Service.Title }}{{ child "content" }}`, 394 | "templates/content.html": `
{{ .Layout.PageTitle }}
{{ .Layout.User }}
{{ .Data.Articles }}
`, 395 | }, 396 | } 397 | 398 | content := New("templates/content.html").ID("content") 399 | content.SetData(map[string]any{ 400 | "Articles": []string{"Article 1", "Article 2", "Article 3"}, 401 | }) 402 | 403 | p := New("templates/index.html").ID("root") 404 | p.With(content) 405 | 406 | out, err := layout.FS(fsys).Set(p).RenderWithRequest(r.Context(), r) 407 | if err != nil { 408 | http.Error(w, err.Error(), http.StatusInternalServerError) 409 | return 410 | } 411 | _, _ = w.Write([]byte(out)) 412 | } 413 | 414 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 415 | response := httptest.NewRecorder() 416 | 417 | handleRequest(response, request) 418 | 419 | expected := "My Page
Home Page
John Doe
[Article 1 Article 2 Article 3]
" 420 | if response.Body.String() != expected { 421 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 422 | } 423 | } 424 | 425 | func TestWithSelectMap(t *testing.T) { 426 | fsys := &InMemoryFS{ 427 | Files: map[string]string{ 428 | "index.gohtml": `{{ child "content" }}`, 429 | "content.gohtml": `
{{selection}}
`, 430 | "tab1.gohtml": "Tab 1 Content", 431 | "tab2.gohtml": "Tab 2 Content", 432 | "default.gohtml": "Default Tab Content", 433 | }, 434 | } 435 | 436 | // Create a map of selection keys to partials 437 | partialsMap := map[string]*Partial{ 438 | "tab1": New("tab1.gohtml").ID("tab1"), 439 | "tab2": New("tab2.gohtml").ID("tab2"), 440 | "default": New("default.gohtml").ID("default"), 441 | } 442 | 443 | // Create the content partial with the selection map 444 | contentPartial := New("content.gohtml"). 445 | ID("content"). 446 | WithSelectMap("default", partialsMap) 447 | 448 | // Create the layout partial 449 | index := New("index.gohtml") 450 | 451 | // Set up the service and layout 452 | svc := NewService(&Config{ 453 | fs: fsys, // Set the file system in the service config 454 | }) 455 | layout := svc.NewLayout(). 456 | Set(contentPartial). 457 | Wrap(index) 458 | 459 | // Set up a test server 460 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 461 | ctx := r.Context() 462 | err := layout.WriteWithRequest(ctx, w, r) 463 | if err != nil { 464 | http.Error(w, err.Error(), http.StatusInternalServerError) 465 | } 466 | }) 467 | 468 | // Create a test server 469 | server := httptest.NewServer(handler) 470 | defer server.Close() 471 | 472 | // Define test cases 473 | testCases := []struct { 474 | name string 475 | selectHeader string 476 | expectedContent string 477 | }{ 478 | { 479 | name: "Select tab1", 480 | selectHeader: "tab1", 481 | expectedContent: "Tab 1 Content", 482 | }, 483 | { 484 | name: "Select tab2", 485 | selectHeader: "tab2", 486 | expectedContent: "Tab 2 Content", 487 | }, 488 | { 489 | name: "Select default", 490 | selectHeader: "", 491 | expectedContent: "Default Tab Content", 492 | }, 493 | { 494 | name: "Invalid selection", 495 | selectHeader: "invalid", 496 | expectedContent: "selected partial 'invalid' not found in parent 'content'", 497 | }, 498 | } 499 | 500 | for _, tc := range testCases { 501 | t.Run(tc.name, func(t *testing.T) { 502 | req, err := http.NewRequest("GET", server.URL, nil) 503 | if err != nil { 504 | t.Fatalf("Failed to create request: %v", err) 505 | } 506 | 507 | if tc.selectHeader != "" { 508 | req.Header.Set("X-Select", tc.selectHeader) 509 | } 510 | 511 | resp, err := http.DefaultClient.Do(req) 512 | if err != nil { 513 | t.Fatalf("Failed to send request: %v", err) 514 | } 515 | defer resp.Body.Close() 516 | 517 | // Read response body 518 | bodyBytes, err := io.ReadAll(resp.Body) 519 | if err != nil { 520 | t.Fatalf("Failed to read response body: %v", err) 521 | } 522 | bodyString := string(bodyBytes) 523 | 524 | // Check if the expected content is in the response 525 | if !strings.Contains(bodyString, tc.expectedContent) { 526 | t.Errorf("Expected response to contain %q, but got %q", tc.expectedContent, bodyString) 527 | } 528 | }) 529 | } 530 | } 531 | 532 | func BenchmarkWithSelectMap(b *testing.B) { 533 | fsys := &InMemoryFS{ 534 | Files: map[string]string{ 535 | "index.gohtml": `{{ child "content" }}`, 536 | "content.gohtml": `
{{selection}}
`, 537 | "tab1.gohtml": "Tab 1 Content", 538 | "tab2.gohtml": "Tab 2 Content", 539 | "default.gohtml": "Default Tab Content", 540 | }, 541 | } 542 | 543 | service := NewService(&Config{ 544 | Connector: connector.NewPartial(nil), 545 | UseCache: false, 546 | }) 547 | layout := service.NewLayout().FS(fsys) 548 | 549 | content := New("content.gohtml"). 550 | ID("content"). 551 | WithSelectMap("default", map[string]*Partial{ 552 | "tab1": New("tab1.gohtml").ID("tab1"), 553 | "tab2": New("tab2.gohtml").ID("tab2"), 554 | "default": New("default.gohtml").ID("default"), 555 | }) 556 | 557 | index := New("index.gohtml") 558 | 559 | layout.Set(content).Wrap(index) 560 | 561 | req := httptest.NewRequest("GET", "/", nil) 562 | 563 | b.ResetTimer() 564 | 565 | for i := 0; i < b.N; i++ { 566 | // Call the function you want to benchmark 567 | _, err := layout.RenderWithRequest(context.Background(), req) 568 | if err != nil { 569 | b.Fatalf("Error rendering: %v", err) 570 | } 571 | } 572 | } 573 | 574 | func BenchmarkRenderWithRequest(b *testing.B) { 575 | // Setup configuration and service 576 | cfg := &Config{ 577 | Connector: connector.NewPartial(nil), 578 | UseCache: false, 579 | } 580 | 581 | service := NewService(cfg) 582 | 583 | fsys := &InMemoryFS{ 584 | Files: map[string]string{ 585 | "templates/index.html": `{{ .Service.Title }}{{ child "content" }}`, 586 | "templates/content.html": `
{{ .Layout.PageTitle }}
{{ .Layout.User }}
{{ .Data.Articles }}
`, 587 | }, 588 | } 589 | 590 | // Create a new layout 591 | layout := service.NewLayout().FS(fsys) 592 | 593 | // Create content partial 594 | content := NewID("content", "templates/content.html") 595 | content.SetData(map[string]any{ 596 | "Title": "Benchmark Test", 597 | "Message": "This is a benchmark test.", 598 | }) 599 | 600 | index := NewID("index", "templates/index.html") 601 | 602 | // Set the content partial in the layout 603 | layout.Set(content).Wrap(index) 604 | 605 | // Create a sample HTTP request 606 | req := httptest.NewRequest("GET", "/", nil) 607 | 608 | // Reset the timer after setup 609 | b.ResetTimer() 610 | 611 | for i := 0; i < b.N; i++ { 612 | // Call the function you want to benchmark 613 | _, err := layout.RenderWithRequest(context.Background(), req) 614 | if err != nil { 615 | b.Fatalf("Error rendering: %v", err) 616 | } 617 | } 618 | } 619 | 620 | func TestRenderTable(t *testing.T) { 621 | svc := NewService(&Config{}) 622 | 623 | var handleRequest = func(w http.ResponseWriter, r *http.Request) { 624 | // Define in-memory templates for the table and the row 625 | fsys := &InMemoryFS{ 626 | Files: map[string]string{ 627 | "templates/table.html": `{{ range $i := .Data.Rows }}{{ child "row" "RowNumber" $i }}{{ end }}
`, 628 | "templates/row.html": `Row {{ .Data.RowNumber }}`, 629 | }, 630 | } 631 | 632 | // Create the row partial 633 | rowPartial := New("templates/row.html").ID("row") 634 | 635 | // Create the table partial and set data 636 | tablePartial := New("templates/table.html").ID("table") 637 | tablePartial.SetData(map[string]any{ 638 | "Rows": []int{1, 2, 3, 4, 5}, // Generate 5 rows 639 | }) 640 | tablePartial.With(rowPartial) 641 | 642 | // Render the table partial 643 | out, err := svc.NewLayout().FS(fsys).Set(tablePartial).RenderWithRequest(r.Context(), r) 644 | if err != nil { 645 | http.Error(w, err.Error(), http.StatusInternalServerError) 646 | return 647 | } 648 | _, _ = w.Write([]byte(out)) 649 | } 650 | 651 | request, _ := http.NewRequest(http.MethodGet, "/", nil) 652 | response := httptest.NewRecorder() 653 | 654 | handleRequest(response, request) 655 | 656 | expected := `
Row 1
Row 2
Row 3
Row 4
Row 5
` 657 | 658 | if strings.TrimSpace(response.Body.String()) != expected { 659 | t.Errorf("expected %s, got %s", expected, response.Body.String()) 660 | } 661 | } 662 | 663 | func TestMergeFuncMap(t *testing.T) { 664 | svc := NewService(&Config{ 665 | FuncMap: template.FuncMap{ 666 | "existingFunc": func() string { return "existing" }, 667 | }, 668 | }) 669 | 670 | svc.MergeFuncMap(template.FuncMap{ 671 | "newFunc": func() string { return "new" }, 672 | "child": func() string { return "should not overwrite" }, 673 | }) 674 | 675 | if _, ok := svc.config.FuncMap["newFunc"]; !ok { 676 | t.Error("newFunc should be added to FuncMap") 677 | } 678 | 679 | if svc.config.FuncMap["newFunc"].(func() string)() != "new" { 680 | t.Error("newFunc should return 'new'") 681 | } 682 | 683 | if _, ok := svc.config.FuncMap["child"]; ok { 684 | t.Error("child should not be overwritten in FuncMap") 685 | } 686 | } 687 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "context" 5 | "html/template" 6 | "io/fs" 7 | "log/slog" 8 | "net/http" 9 | "sync" 10 | 11 | "github.com/donseba/go-partial/connector" 12 | ) 13 | 14 | type ( 15 | Logger interface { 16 | Warn(msg string, args ...any) 17 | Error(msg string, args ...any) 18 | } 19 | 20 | Config struct { 21 | Connector connector.Connector 22 | UseCache bool 23 | FuncMap template.FuncMap 24 | Logger Logger 25 | fs fs.FS 26 | } 27 | 28 | Service struct { 29 | config *Config 30 | data map[string]any 31 | combinedFunctions template.FuncMap 32 | connector connector.Connector 33 | funcMapLock sync.RWMutex // Add a read-write mutex 34 | } 35 | 36 | Layout struct { 37 | service *Service 38 | filesystem fs.FS 39 | content *Partial 40 | wrapper *Partial 41 | data map[string]any 42 | request *http.Request 43 | combinedFunctions template.FuncMap 44 | connector connector.Connector 45 | funcMapLock sync.RWMutex // Add a read-write mutex 46 | } 47 | ) 48 | 49 | // NewService returns a new partial service. 50 | func NewService(cfg *Config) *Service { 51 | if cfg.FuncMap == nil { 52 | cfg.FuncMap = DefaultTemplateFuncMap 53 | } 54 | 55 | if cfg.Logger == nil { 56 | cfg.Logger = slog.Default().WithGroup("partial") 57 | } 58 | 59 | return &Service{ 60 | config: cfg, 61 | data: make(map[string]any), 62 | funcMapLock: sync.RWMutex{}, 63 | combinedFunctions: cfg.FuncMap, 64 | connector: cfg.Connector, 65 | } 66 | } 67 | 68 | // NewLayout returns a new layout. 69 | func (svc *Service) NewLayout() *Layout { 70 | return &Layout{ 71 | service: svc, 72 | data: make(map[string]any), 73 | filesystem: svc.config.fs, 74 | connector: svc.connector, 75 | combinedFunctions: svc.getFuncMap(), 76 | } 77 | } 78 | 79 | // SetData sets the data for the Service. 80 | func (svc *Service) SetData(data map[string]any) *Service { 81 | svc.data = data 82 | return svc 83 | } 84 | 85 | // AddData adds data to the Service. 86 | func (svc *Service) AddData(key string, value any) *Service { 87 | svc.data[key] = value 88 | return svc 89 | } 90 | 91 | func (svc *Service) SetConnector(conn connector.Connector) *Service { 92 | svc.connector = conn 93 | return svc 94 | } 95 | 96 | // MergeFuncMap merges the given FuncMap with the existing FuncMap. 97 | func (svc *Service) MergeFuncMap(funcMap template.FuncMap) { 98 | svc.funcMapLock.Lock() 99 | defer svc.funcMapLock.Unlock() 100 | 101 | for k, v := range funcMap { 102 | if _, ok := protectedFunctionNames[k]; ok { 103 | svc.config.Logger.Warn("function name is protected and cannot be overwritten", "function", k) 104 | continue 105 | } 106 | // Modify the existing map directly 107 | svc.combinedFunctions[k] = v 108 | } 109 | } 110 | 111 | func (svc *Service) getFuncMap() template.FuncMap { 112 | svc.funcMapLock.RLock() 113 | defer svc.funcMapLock.RUnlock() 114 | return svc.combinedFunctions 115 | } 116 | 117 | // FS sets the filesystem for the Layout. 118 | func (l *Layout) FS(fs fs.FS) *Layout { 119 | l.filesystem = fs 120 | return l 121 | } 122 | 123 | // Set sets the content for the layout. 124 | func (l *Layout) Set(p *Partial) *Layout { 125 | l.content = p 126 | l.applyConfigToPartial(l.content) 127 | return l 128 | } 129 | 130 | // Wrap sets the wrapper for the layout. 131 | func (l *Layout) Wrap(p *Partial) *Layout { 132 | l.wrapper = p 133 | l.applyConfigToPartial(l.wrapper) 134 | return l 135 | } 136 | 137 | // SetData sets the data for the layout. 138 | func (l *Layout) SetData(data map[string]any) *Layout { 139 | l.data = data 140 | return l 141 | } 142 | 143 | // AddData adds data to the layout. 144 | func (l *Layout) AddData(key string, value any) *Layout { 145 | l.data[key] = value 146 | return l 147 | } 148 | 149 | // MergeFuncMap merges the given FuncMap with the existing FuncMap in the Layout. 150 | func (l *Layout) MergeFuncMap(funcMap template.FuncMap) { 151 | l.funcMapLock.Lock() 152 | defer l.funcMapLock.Unlock() 153 | 154 | for k, v := range funcMap { 155 | if _, ok := protectedFunctionNames[k]; ok { 156 | l.service.config.Logger.Warn("function name is protected and cannot be overwritten", "function", k) 157 | continue 158 | } 159 | // Modify the existing map directly 160 | l.combinedFunctions[k] = v 161 | } 162 | } 163 | 164 | func (l *Layout) getFuncMap() template.FuncMap { 165 | l.funcMapLock.RLock() 166 | defer l.funcMapLock.RUnlock() 167 | 168 | return l.combinedFunctions 169 | } 170 | 171 | // RenderWithRequest renders the partial with the given http.Request. 172 | func (l *Layout) RenderWithRequest(ctx context.Context, r *http.Request) (template.HTML, error) { 173 | l.request = r 174 | 175 | if l.wrapper != nil { 176 | l.wrapper.With(l.content) 177 | // Render the wrapper 178 | return l.wrapper.RenderWithRequest(ctx, r) 179 | } else { 180 | // Render the content directly 181 | return l.content.RenderWithRequest(ctx, r) 182 | } 183 | } 184 | 185 | // WriteWithRequest writes the layout to the response writer. 186 | func (l *Layout) WriteWithRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error { 187 | l.request = r 188 | 189 | if l.connector.RenderPartial(r) { 190 | if l.wrapper != nil { 191 | l.content.parent = l.wrapper 192 | } 193 | err := l.content.WriteWithRequest(ctx, w, r) 194 | if err != nil { 195 | if l.service.config.Logger != nil { 196 | l.service.config.Logger.Error("error rendering layout", "error", err) 197 | } 198 | return err 199 | } 200 | return nil 201 | } 202 | 203 | if l.wrapper != nil { 204 | l.wrapper.With(l.content) 205 | 206 | err := l.wrapper.WriteWithRequest(ctx, w, r) 207 | if err != nil { 208 | if l.service.config.Logger != nil { 209 | l.service.config.Logger.Error("error rendering layout", "error", err) 210 | } 211 | return err 212 | } 213 | } 214 | 215 | return nil 216 | } 217 | 218 | func (l *Layout) applyConfigToPartial(p *Partial) { 219 | if p == nil { 220 | return 221 | } 222 | 223 | // Combine functions only once 224 | combinedFunctions := l.getFuncMap() 225 | 226 | p.mergeFuncMapInternal(combinedFunctions) 227 | 228 | p.connector = l.service.connector 229 | if l.filesystem != nil { 230 | p.fs = l.filesystem 231 | } 232 | if l.service.config.Logger != nil { 233 | p.logger = l.service.config.Logger 234 | } 235 | p.useCache = l.service.config.UseCache 236 | p.globalData = l.service.data 237 | p.layoutData = l.data 238 | p.request = l.request 239 | } 240 | -------------------------------------------------------------------------------- /template_functions.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "net/url" 7 | "strings" 8 | "time" 9 | "unicode" 10 | ) 11 | 12 | var DefaultTemplateFuncMap = template.FuncMap{ 13 | "safeHTML": safeHTML, 14 | // String functions 15 | "upper": strings.ToUpper, 16 | "lower": strings.ToLower, 17 | "trimSpace": strings.TrimSpace, 18 | "trim": strings.Trim, 19 | "trimSuffix": strings.TrimSuffix, 20 | "trimPrefix": strings.TrimPrefix, 21 | "contains": strings.Contains, 22 | "containsAny": strings.ContainsAny, 23 | "hasPrefix": strings.HasPrefix, 24 | "hasSuffix": strings.HasSuffix, 25 | "repeat": strings.Repeat, 26 | "replace": strings.Replace, 27 | "split": strings.Split, 28 | "join": strings.Join, 29 | "stringSlice": stringSlice, 30 | "title": title, 31 | "substr": substr, 32 | "ucfirst": ucfirst, 33 | "compare": strings.Compare, 34 | "equalFold": strings.EqualFold, 35 | "urlencode": url.QueryEscape, 36 | "urldecode": url.QueryUnescape, 37 | // Time functions 38 | 39 | "now": time.Now, 40 | "formatDate": formatDate, 41 | "parseDate": parseDate, 42 | 43 | // List functions 44 | "first": first, 45 | "last": last, 46 | 47 | // Map functions 48 | "hasKey": hasKey, 49 | "keys": keys, 50 | 51 | // Debug functions 52 | "debug": debug, 53 | } 54 | 55 | func safeHTML(s string) template.HTML { 56 | return template.HTML(s) 57 | } 58 | 59 | // ucfirst capitalizes the first character of the string. 60 | func ucfirst(s string) string { 61 | if s == "" { 62 | return "" 63 | } 64 | runes := []rune(s) 65 | runes[0] = unicode.ToUpper(runes[0]) 66 | return string(runes) 67 | } 68 | 69 | func stringSlice(values ...string) []string { 70 | return values 71 | } 72 | 73 | // title capitalizes the first character of each word in the string. 74 | func title(s string) string { 75 | if s == "" { 76 | return "" 77 | } 78 | runes := []rune(s) 79 | length := len(runes) 80 | capitalizeNext := true 81 | for i := 0; i < length; i++ { 82 | if unicode.IsSpace(runes[i]) { 83 | capitalizeNext = true 84 | } else if capitalizeNext { 85 | runes[i] = unicode.ToUpper(runes[i]) 86 | capitalizeNext = false 87 | } else { 88 | runes[i] = unicode.ToLower(runes[i]) 89 | } 90 | } 91 | return string(runes) 92 | } 93 | 94 | // substr returns a substring starting at 'start' position with 'length' characters. 95 | func substr(s string, start, length int) string { 96 | runes := []rune(s) 97 | if start >= len(runes) || length <= 0 { 98 | return "" 99 | } 100 | end := start + length 101 | if end > len(runes) { 102 | end = len(runes) 103 | } 104 | return string(runes[start:end]) 105 | } 106 | 107 | // first returns the first element of the list. 108 | func first(a []any) any { 109 | if len(a) > 0 { 110 | return a[0] 111 | } 112 | return nil 113 | } 114 | 115 | // last returns the last element of the list. 116 | func last(a []any) any { 117 | if len(a) > 0 { 118 | return a[len(a)-1] 119 | } 120 | return nil 121 | } 122 | 123 | // hasKey checks if the map has the key. 124 | func hasKey(m map[string]any, key string) bool { 125 | _, ok := m[key] 126 | return ok 127 | } 128 | 129 | // keys returns the keys of the map. 130 | func keys(m map[string]any) []string { 131 | out := make([]string, 0, len(m)) 132 | for k := range m { 133 | out = append(out, k) 134 | } 135 | return out 136 | } 137 | 138 | // formatDate formats the time with the layout. 139 | func formatDate(t time.Time, layout string) string { 140 | return t.Format(layout) 141 | } 142 | 143 | // parseDate parses the time with the layout. 144 | func parseDate(layout, value string) (time.Time, error) { 145 | return time.Parse(layout, value) 146 | } 147 | 148 | // debug returns the string representation of the value. 149 | func debug(v any) string { 150 | return fmt.Sprintf("%+v", v) 151 | } 152 | 153 | func selectionFunc(p *Partial, data *Data) func() template.HTML { 154 | return func() template.HTML { 155 | var selectedPartial *Partial 156 | 157 | partials := p.getSelectionPartials() 158 | if partials == nil { 159 | p.getLogger().Error("no selection partials found", "id", p.id) 160 | return template.HTML(fmt.Sprintf("no selection partials found in parent '%s'", p.id)) 161 | } 162 | 163 | requestedSelect := p.getConnector().GetSelectValue(p.GetRequest()) 164 | if requestedSelect != "" { 165 | selectedPartial = partials[requestedSelect] 166 | } else { 167 | selectedPartial = partials[p.selection.Default] 168 | } 169 | 170 | if selectedPartial == nil { 171 | p.getLogger().Error("selected partial not found", "id", requestedSelect, "parent", p.id) 172 | return template.HTML(fmt.Sprintf("selected partial '%s' not found in parent '%s'", requestedSelect, p.id)) 173 | } 174 | 175 | selectedPartial.fs = p.fs 176 | 177 | html, err := selectedPartial.renderSelf(data.Ctx, p.GetRequest()) 178 | if err != nil { 179 | p.getLogger().Error("error rendering selected partial", "id", requestedSelect, "parent", p.id, "error", err) 180 | return template.HTML(fmt.Sprintf("error rendering selected partial '%s'", requestedSelect)) 181 | } 182 | 183 | return html 184 | } 185 | } 186 | 187 | func childFunc(p *Partial, data *Data) func(id string, vals ...any) template.HTML { 188 | return func(id string, vals ...any) template.HTML { 189 | if len(vals) > 0 && len(vals)%2 != 0 { 190 | p.getLogger().Warn("invalid child data for partial, they come in key-value pairs", "id", id) 191 | return template.HTML(fmt.Sprintf("invalid child data for partial '%s'", id)) 192 | } 193 | 194 | d := make(map[string]any) 195 | for i := 0; i < len(vals); i += 2 { 196 | key, ok := vals[i].(string) 197 | if !ok { 198 | p.getLogger().Warn("invalid child data key for partial, it must be a string", "id", id, "key", vals[i]) 199 | return template.HTML(fmt.Sprintf("invalid child data key for partial '%s', want string, got %T", id, vals[i])) 200 | } 201 | d[key] = vals[i+1] 202 | } 203 | 204 | html, err := p.renderChildPartial(data.Ctx, id, d) 205 | if err != nil { 206 | p.getLogger().Error("error rendering partial", "id", id, "error", err) 207 | // Handle error: you can log it and return an empty string or an error message 208 | return template.HTML(fmt.Sprintf("error rendering partial '%s': %v", id, err)) 209 | } 210 | 211 | return html 212 | } 213 | } 214 | 215 | func actionFunc(p *Partial, data *Data) func() template.HTML { 216 | return func() template.HTML { 217 | if p.templateAction == nil { 218 | p.getLogger().Error("no action callback found", "id", p.id) 219 | return template.HTML(fmt.Sprintf("no action callback found in partial '%s'", p.id)) 220 | } 221 | 222 | // Use the selector to get the appropriate partial 223 | actionPartial, err := p.templateAction(data.Ctx, p, data) 224 | if err != nil { 225 | p.getLogger().Error("error in selector function", "error", err) 226 | return template.HTML(fmt.Sprintf("error in action function: %v", err)) 227 | } 228 | 229 | // Render the selected partial instead 230 | html, err := actionPartial.renderSelf(data.Ctx, p.GetRequest()) 231 | if err != nil { 232 | p.getLogger().Error("error rendering action partial", "error", err) 233 | return template.HTML(fmt.Sprintf("error rendering action partial: %v", err)) 234 | } 235 | return html 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /template_functions_test.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | // Tests 12 | func TestSafeHTML(t *testing.T) { 13 | input := "

Hello, World!

" 14 | expected := template.HTML("

Hello, World!

") 15 | output := safeHTML(input) 16 | if output != expected { 17 | t.Errorf("safeHTML(%q) = %q; want %q", input, output, expected) 18 | } 19 | } 20 | 21 | func TestTitle(t *testing.T) { 22 | cases := []struct { 23 | input string 24 | expected string 25 | }{ 26 | {"hello world", "Hello World"}, 27 | {"HELLO WORLD", "Hello World"}, 28 | {"go is awesome", "Go Is Awesome"}, 29 | {"", ""}, 30 | // Test cases with accented characters 31 | {"élan vital", "Élan Vital"}, 32 | {"über cool", "Über Cool"}, 33 | {"façade", "Façade"}, 34 | {"mañana", "Mañana"}, 35 | {"crème brûlée", "Crème Brûlée"}, 36 | // Test cases with non-Latin scripts 37 | {"россия", "Россия"}, // Russian (Cyrillic script) 38 | {"中国", "中国"}, // Chinese characters 39 | {"こんにちは 世界", "こんにちは 世界"}, // Japanese (Hiragana and Kanji) 40 | {"مرحبا بالعالم", "مرحبا بالعالم"}, // Arabic script 41 | {"γειά σου κόσμε", "Γειά Σου Κόσμε"}, // Greek script 42 | // Test cases with mixed scripts 43 | {"hello 世界", "Hello 世界"}, 44 | {"こんにちは world", "こんにちは World"}, 45 | } 46 | for _, c := range cases { 47 | output := title(c.input) 48 | if output != c.expected { 49 | t.Errorf("title(%q) = %q; want %q", c.input, output, c.expected) 50 | } 51 | } 52 | } 53 | 54 | func TestSubstr(t *testing.T) { 55 | cases := []struct { 56 | input string 57 | start int 58 | length int 59 | expected string 60 | }{ 61 | {"Hello, World!", 7, 5, "World"}, 62 | {"Hello, World!", 0, 5, "Hello"}, 63 | {"Hello, World!", 7, 20, "World!"}, 64 | {"Hello, World!", 20, 5, ""}, 65 | {"Hello, World!", 0, 0, ""}, 66 | } 67 | for _, c := range cases { 68 | output := substr(c.input, c.start, c.length) 69 | if output != c.expected { 70 | t.Errorf("substr(%q, %d, %d) = %q; want %q", c.input, c.start, c.length, output, c.expected) 71 | } 72 | } 73 | } 74 | 75 | func TestUcFirst(t *testing.T) { 76 | cases := []struct { 77 | input string 78 | expected string 79 | }{ 80 | {"hello world", "Hello world"}, 81 | {"Hello world", "Hello world"}, 82 | {"h", "H"}, 83 | {"", ""}, 84 | // Test cases with accented characters 85 | {"élan vital", "Élan vital"}, 86 | {"über cool", "Über cool"}, 87 | {"façade", "Façade"}, 88 | {"mañana", "Mañana"}, 89 | {"crème brûlée", "Crème brûlée"}, 90 | // Test cases with non-Latin scripts 91 | {"россия", "Россия"}, // Russian (Cyrillic script) 92 | {"中国", "中国"}, // Chinese characters 93 | {"こんにちは 世界", "こんにちは 世界"}, // Japanese (Hiragana and Kanji) 94 | {"مرحبا بالعالم", "مرحبا بالعالم"}, // Arabic script 95 | {"γειά σου κόσμε", "Γειά σου κόσμε"}, // Greek script 96 | // Test cases with mixed scripts 97 | {"hello 世界", "Hello 世界"}, 98 | {"こんにちは world", "こんにちは world"}, 99 | } 100 | for _, c := range cases { 101 | output := ucfirst(c.input) 102 | if output != c.expected { 103 | t.Errorf("ucfirst(%q) = %q; want %q", c.input, output, c.expected) 104 | } 105 | } 106 | } 107 | 108 | func TestFormatDate(t *testing.T) { 109 | t1 := time.Date(2021, time.December, 31, 23, 59, 59, 0, time.UTC) 110 | cases := []struct { 111 | input time.Time 112 | layout string 113 | expected string 114 | }{ 115 | {t1, "2006-01-02", "2021-12-31"}, 116 | {t1, "Jan 2, 2006", "Dec 31, 2021"}, 117 | {t1, time.RFC3339, "2021-12-31T23:59:59Z"}, 118 | } 119 | for _, c := range cases { 120 | output := formatDate(c.input, c.layout) 121 | if output != c.expected { 122 | t.Errorf("formatDate(%v, %q) = %q; want %q", c.input, c.layout, output, c.expected) 123 | } 124 | } 125 | } 126 | 127 | func TestParseDate(t *testing.T) { 128 | cases := []struct { 129 | layout string 130 | value string 131 | expected time.Time 132 | wantErr bool 133 | }{ 134 | {"2006-01-02", "2021-12-31", time.Date(2021, time.December, 31, 0, 0, 0, 0, time.UTC), false}, 135 | {"Jan 2, 2006", "Dec 31, 2021", time.Date(2021, time.December, 31, 0, 0, 0, 0, time.UTC), false}, 136 | {"2006-01-02", "invalid date", time.Time{}, true}, 137 | } 138 | for _, c := range cases { 139 | output, err := parseDate(c.layout, c.value) 140 | if (err != nil) != c.wantErr { 141 | t.Errorf("parseDate(%q, %q) error = %v; wantErr %v", c.layout, c.value, err, c.wantErr) 142 | continue 143 | } 144 | if !c.wantErr && !output.Equal(c.expected) { 145 | t.Errorf("parseDate(%q, %q) = %v; want %v", c.layout, c.value, output, c.expected) 146 | } 147 | } 148 | } 149 | 150 | func TestFirst(t *testing.T) { 151 | cases := []struct { 152 | input []any 153 | expected any 154 | }{ 155 | {[]any{1, 2, 3}, 1}, 156 | {[]any{"a", "b", "c"}, "a"}, 157 | {[]any{}, nil}, 158 | } 159 | for _, c := range cases { 160 | output := first(c.input) 161 | if !reflect.DeepEqual(output, c.expected) { 162 | t.Errorf("first(%v) = %v; want %v", c.input, output, c.expected) 163 | } 164 | } 165 | } 166 | 167 | func TestLast(t *testing.T) { 168 | cases := []struct { 169 | input []any 170 | expected any 171 | }{ 172 | {[]any{1, 2, 3}, 3}, 173 | {[]any{"a", "b", "c"}, "c"}, 174 | {[]any{}, nil}, 175 | } 176 | for _, c := range cases { 177 | output := last(c.input) 178 | if !reflect.DeepEqual(output, c.expected) { 179 | t.Errorf("last(%v) = %v; want %v", c.input, output, c.expected) 180 | } 181 | } 182 | } 183 | 184 | func TestHasKey(t *testing.T) { 185 | cases := []struct { 186 | input map[string]any 187 | key string 188 | expected bool 189 | }{ 190 | {map[string]any{"a": 1, "b": 2}, "a", true}, 191 | {map[string]any{"a": 1, "b": 2}, "c", false}, 192 | {map[string]any{}, "a", false}, 193 | } 194 | for _, c := range cases { 195 | output := hasKey(c.input, c.key) 196 | if output != c.expected { 197 | t.Errorf("hasKey(%v, %q) = %v; want %v", c.input, c.key, output, c.expected) 198 | } 199 | } 200 | } 201 | 202 | func TestKeys(t *testing.T) { 203 | cases := []struct { 204 | input map[string]any 205 | expected []string 206 | }{ 207 | {map[string]any{"a": 1, "b": 2}, []string{"a", "b"}}, 208 | {map[string]any{}, []string{}}, 209 | } 210 | for _, c := range cases { 211 | output := keys(c.input) 212 | if !equalStringSlices(output, c.expected) { 213 | t.Errorf("keys(%v) = %v; want %v", c.input, output, c.expected) 214 | } 215 | } 216 | } 217 | 218 | // Helper function to compare slices regardless of order 219 | func equalStringSlices(a, b []string) bool { 220 | if len(a) != len(b) { 221 | return false 222 | } 223 | aMap := make(map[string]int) 224 | bMap := make(map[string]int) 225 | for _, v := range a { 226 | aMap[v]++ 227 | } 228 | for _, v := range b { 229 | bMap[v]++ 230 | } 231 | return reflect.DeepEqual(aMap, bMap) 232 | } 233 | 234 | func TestDebug(t *testing.T) { 235 | input := map[string]any{"a": 1, "b": "test"} 236 | expected := fmt.Sprintf("%+v", input) 237 | output := debug(input) 238 | if output != expected { 239 | t.Errorf("debug(%v) = %q; want %q", input, output, expected) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /tree.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | type Node struct { 4 | ID string 5 | Depth int 6 | Nodes []*Node 7 | } 8 | 9 | // Tree returns the tree of partials. 10 | func Tree(p *Partial) *Node { 11 | return tree(p, 0) 12 | } 13 | 14 | func tree(p *Partial, depth int) *Node { 15 | var out = &Node{ID: p.id, Depth: depth} 16 | 17 | for _, child := range p.children { 18 | out.Nodes = append(out.Nodes, tree(child, depth+1)) 19 | } 20 | 21 | return out 22 | } 23 | -------------------------------------------------------------------------------- /tree_test.go: -------------------------------------------------------------------------------- 1 | package partial 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestTree(t *testing.T) { 8 | p := New("template1", "template2").ID("root") 9 | child := New("template1", "template2").ID("id") 10 | oobChild := New("template1", "template2").ID("id1") 11 | 12 | child.With(oobChild) 13 | 14 | p.With(child) 15 | p.WithOOB(oobChild) 16 | 17 | tr := Tree(p) 18 | 19 | if tr.ID != "root" { 20 | t.Errorf("expected root id to be root, got %s", tr.ID) 21 | } 22 | 23 | if tr.Nodes == nil { 24 | t.Errorf("expected nodes to be non-nil") 25 | } 26 | 27 | if len(tr.Nodes) != 2 { 28 | t.Errorf("expected 2 node, got %d", len(tr.Nodes)) 29 | } 30 | 31 | if tr.Nodes[0].ID != "id" { 32 | t.Errorf("expected id to be id∂, got %s", tr.Nodes[0].ID) 33 | } 34 | 35 | if tr.Nodes[1].ID != "id1" { 36 | t.Errorf("expected id to be id1, got %s", tr.Nodes[1].ID) 37 | } 38 | 39 | if tr.Nodes[0].Nodes == nil { 40 | t.Errorf("expected nodes to be non-nil") 41 | } 42 | 43 | if len(tr.Nodes[0].Nodes) != 1 { 44 | t.Errorf("expected 1 node, got %d", len(tr.Nodes[0].Nodes)) 45 | } 46 | 47 | if tr.Nodes[0].Nodes[0].ID != "id1" { 48 | t.Errorf("expected id to be id1, got %s", tr.Nodes[0].Nodes[0].ID) 49 | } 50 | } 51 | --------------------------------------------------------------------------------