├── .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 |
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 |
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 |
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 |
395 |
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
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 |
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 |
(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 |
(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 |
Loading...
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 |
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 |
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 = '';
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": "