├── .dockerignore ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── tasks.json └── vscode_config_extensions.json ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── app ├── README.md └── app.go ├── build.go ├── cmd └── main.go ├── config.go ├── dot.go ├── dot_db.go ├── dot_db_config.go ├── dot_flags.go ├── dot_flush.go ├── dot_fs.go ├── dot_fs_config.go ├── dot_instance.go ├── dot_kv.go ├── dot_nats.go ├── dot_nats_config.go ├── dot_req.go ├── dot_resp.go ├── frontmatter.go ├── funcs.go ├── go.mod ├── go.sum ├── handlers.go ├── instance.go ├── make_tool.cue ├── server.go └── test ├── .gitattributes ├── .gitignore ├── caddy.json ├── config.json ├── data ├── foo.txt ├── hello.txt └── subdir │ └── world.txt ├── migrations ├── manual.1.sql ├── manual.2.sql ├── schema.1.sql ├── schema.10.sql └── schema.2.sql ├── templates ├── .migration.html ├── assets │ ├── empty.txt │ ├── file.txt │ ├── file.txt.gz │ ├── reset.css │ ├── reset.css.br │ ├── reset.css.gz │ └── standalone.gz ├── db │ ├── .init.html │ ├── index.html │ └── manual.html ├── favicon.ico ├── flags │ └── index.html ├── fs │ ├── browse │ │ └── {filepath...}.html │ ├── openclose.html │ └── serve.html ├── index{$}.html ├── nats │ └── index.html ├── ready.html ├── routing │ ├── .hidden.html │ ├── file.html │ ├── index.html │ └── visible.html └── sse │ ├── hotreload.html │ └── test.html └── tests ├── assets.hurl ├── db.hurl ├── flags.hurl ├── fs.hurl ├── nats.hurl.disabled ├── routing.hurl └── sse.hurl /.dockerignore: -------------------------------------------------------------------------------- 1 | go.work* 2 | xtemplate* 3 | caddy 4 | Dockerfile 5 | .dockerignore 6 | .gitignore 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: '1.23' 16 | - uses: gacts/install-hurl@v1 17 | - uses: cue-lang/setup-cue@v1.0.0 18 | - run: go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest 19 | 20 | - name: Check secrets availability 21 | id: secrets_available 22 | continue-on-error: true 23 | run: | 24 | [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ] && echo "::error::Secrets unavailable" && exit 1 || exit 0 25 | - name: Login to Docker Hub 26 | uses: docker/login-action@v3 27 | if: ${{ steps.secrets_available.conclusion == 'success' }} 28 | with: 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.DOCKERHUB_TOKEN }} 31 | 32 | # CUE_DEBUG_TOOLS_FLOW=true cue cmd ci 33 | - run: cue cmd ci 34 | 35 | - name: Archive test results 36 | uses: actions/upload-artifact@v4 37 | if: always() 38 | with: 39 | name: logs 40 | path: | 41 | test/**/*.log 42 | test/**/report/ 43 | 44 | - uses: actions/upload-artifact@v4 45 | with: 46 | name: xtemplate-amd64-linux 47 | path: 'dist/xtemplate-amd64-linux/*' 48 | 49 | - uses: actions/upload-artifact@v4 50 | with: 51 | name: xtemplate-amd64-darwin 52 | path: 'dist/xtemplate-amd64-darwin/*' 53 | 54 | - uses: actions/upload-artifact@v4 55 | with: 56 | name: xtemplate-amd64-windows 57 | path: 'dist/xtemplate-amd64-windows/*' 58 | 59 | - name: Release 60 | if: startsWith(github.ref, 'refs/tags/v') 61 | uses: softprops/action-gh-release@v1 62 | with: 63 | files: 'dist/*.zip' 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | go.work* 2 | xtemplate 3 | xtemplate.* 4 | caddy 5 | dist 6 | __debug_bin* 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "golang.go", 4 | "cuelangorg.vscode-cue" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug xtemplate", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "debug", 9 | "showLog": true, 10 | "program": "${workspaceFolder}/cmd", 11 | "cwd": "${workspaceFolder}/test", 12 | "args": [ 13 | "--loglevel", 14 | "-4", 15 | "--listen", 16 | ":8080", 17 | "--config-file", 18 | "config.json" 19 | ], 20 | "env": { 21 | "CGO_ENABLED": "1" 22 | } 23 | }, 24 | { 25 | "name": "Debug xtemplate-caddy", 26 | "type": "go", 27 | "request": "launch", 28 | "mode": "debug", 29 | "showLog": true, 30 | "program": "${workspaceFolder}/test/caddy", 31 | "cwd": "${workspaceFolder}/test", 32 | "args": [ 33 | "run", 34 | "--config", 35 | "caddy.json" 36 | ] 37 | }, 38 | ] 39 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Test", 8 | "type": "shell", 9 | "command": "hurl --test --glob '${workspaceFolder}/test/tests/*.hurl'", 10 | "problemMatcher": [] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /.vscode/vscode_config_extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["golang.go"] 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-alpine AS deps 2 | 3 | RUN apk add --no-cache build-base 4 | 5 | WORKDIR /src 6 | COPY go.mod go.sum ./ 7 | RUN go mod download -x 8 | 9 | ### 10 | 11 | FROM deps AS build 12 | 13 | ARG LDFLAGS 14 | 15 | COPY app ./app/ 16 | COPY cmd ./cmd/ 17 | COPY *.go ./ 18 | RUN CGO_ENABLED=1 \ 19 | GOFLAGS='-tags="sqlite_json"' \ 20 | GOOS=linux \ 21 | GOARCH=amd64 \ 22 | go build -x -ldflags="${LDFLAGS} -X 'github.com/infogulch/xtemplate/app.defaultWatchTemplates=false' -X 'github.com/infogulch/xtemplate/app.defaultListenAddress=0.0.0.0:80'" -o /build/xtemplate ./cmd 23 | 24 | ### 25 | 26 | FROM alpine AS dist 27 | 28 | ENV USER=appuser 29 | ENV UID=10001 30 | RUN adduser \ 31 | --disabled-password \ 32 | --gecos "" \ 33 | --home "/nonexistent" \ 34 | --shell "/sbin/nologin" \ 35 | --no-create-home \ 36 | --uid "${UID}" \ 37 | "${USER}" 38 | WORKDIR /app 39 | USER $USER:$USER 40 | EXPOSE 80 41 | 42 | COPY --from=build /build/xtemplate /app/xtemplate 43 | 44 | ENTRYPOINT ["/app/xtemplate"] 45 | 46 | ### 47 | 48 | FROM dist AS test 49 | 50 | COPY ./test/templates /app/templates/ 51 | COPY ./test/data /app/data/ 52 | COPY ./test/migrations /app/migrations/ 53 | COPY ./test/config.json /app/ 54 | 55 | USER root:root 56 | RUN mkdir /app/dataw 57 | 58 | VOLUME /app/dataw 59 | 60 | RUN ["/app/xtemplate", "--version"] 61 | 62 | WORKDIR /app/dataw 63 | 64 | CMD ["--loglevel", "-4", "--config-file", "../config.json"] 65 | 66 | ### 67 | 68 | FROM dist as final 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015 Matthew Holt and The Caddy Authors 190 | Copyright 2024 Joseph Taber (infogulch) 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xtemplate 2 | 3 | `xtemplate` is a html/template-based hypertext preprocessor and rapid 4 | application development web server written in Go. It streamlines construction of 5 | hypermedia-exchange-oriented web sites by efficiently handling basic server 6 | tasks, enabling authors to focus on defining routes and responding to them using 7 | templates and configurable data sources. 8 | 9 | ## 🎯 Goal 10 | 11 | After bulding some sites with [htmx](https://htmx.org) and Go, I wished that 12 | everything would just get out of the way of the fundamentals: 13 | 14 | - URLs and path patterns 15 | - Access to a backing data source 16 | - Executing a template to return HTML 17 | 18 | 🎇 **The idea of `xtemplate` is that *templates* can be the nexus of these 19 | fundamentals.** 20 | 21 |
🚫 Anti-goals 22 | 23 | `xtemplate` needs to implement some of the things that are required to make a 24 | good web server in a way that avoids common issues with existing web server 25 | designs, otherwise they'll be in the way of the fundamentals: 26 | 27 | * **Rigid template behavior**: Most designs relegate templates to be dumb string 28 | concatenators with just enough dynamic behavior to walk over some fixed data 29 | structure. 30 | * **Inefficient template loading**: Some designs read template files from disk 31 | and parse them on every request. This seems wasteful when the web server 32 | definition is typically static. 33 | * **Constant rebuilds**: On the other end of the spectrum, some designs require 34 | rebuilding the entire server from source when any little thing changes. This 35 | seems wasteful and makes graceful restarts more difficult than necessary when 36 | all you're doing is changing a button name. 37 | * **Repetitive route definitions**: Why should you have to name a http handler 38 | and add it to a central registry (or maintain a pile of code that plumbs these 39 | together for you) when new routes are often only relevant to the local html? 40 | * **Default unsafe**: Some designs require authors to vigilantly escape user 41 | inputs, risking XSS attacks that could have been avoided with less effort. 42 | * **Inefficient asset serving**: Some designs compress static assets at request 43 | time, instead of serving pre-compressed content with sendfile(2) and 44 | negotiated content encoding. Most designs don't give templates access to the 45 | hash of asset files, depriving clients of enough information to optimize 46 | caching behavior and check resource integrity. 47 | 48 |
49 | 50 | ## ✨ Features 51 | 52 | *Click a feature to expand and show details:* 53 | 54 |
⚡ Efficient design 55 | 56 | > All template files are read and parsed *once*, at startup, and kept in memory 57 | > during the life of an xtemplate *instance*. Requests are routed to a handler 58 | > that immediately starts executing a template reference in response. No slow 59 | > cascading disk accesses or parsing overhead before you even begin crafting the 60 | > response. 61 |
62 | 63 |
🔄 Live reload 64 | 65 | > Template files are loaded into a new instance and validated milliseconds after 66 | > they are modified, no need to restart the server. If an error occurs during 67 | > load the previous instance remains intact and continues to serve while the 68 | > loading error is printed to the logs. A successful reload atomically swaps the 69 | > handler so new requests are served by the new instance; pending requests are 70 | > allowed to complete gracefully. 71 | > 72 | > Add this template definition and one-line script to your page, then 73 | > clients will automatically reload when the server does: 74 | > 75 | > ```html 76 | > {{- define "SSE /reload"}}{{.WaitForServerStop}}data: reload{{printf "\n\n"}}{{end}} 77 | > 78 | > 79 | > ``` 80 |
81 | 82 |
🗃️ Simple file-based routing 83 | 84 | > `GET` requests are handled by invoking a matching template file at that path. 85 | > (Hidden files that start with `.` are loaded but not routed by default.) 86 | > 87 | > ``` 88 | > File path: HTTP path: 89 | > . 90 | > ├── index.html GET / 91 | > ├── todos.html GET /todos 92 | > ├── admin 93 | > │ └── settings.html GET /admin/settings 94 | > └── shared 95 | > └── .head.html (not routed because it starts with '.') 96 | > ``` 97 |
98 | 99 |
🔱 Add custom routes to handle any method and path pattern 100 | 101 | > Handle any [Go 1.22 ServeMux][servemux] pattern by **defining a template with 102 | > that pattern as its name**. Path placeholders are available during template 103 | > execution with the `.Req.PathValue` method. 104 | > 105 | > ```html 106 | > 107 | > {{define "GET /contact/{id}"}} 108 | > {{$contact := .QueryRow `SELECT name,phone FROM contacts WHERE id=?` (.Req.PathValue "id")}} 109 | >
110 | > Name: {{$contact.name}} 111 | > Phone: {{$contact.phone}} 112 | >
113 | > {{end}} 114 | > 115 | > 116 | > {{define "DELETE /contact/{id}"}} 117 | > {{$_ := .Exec `DELETE from contacts WHERE id=?` (.Req.PathValue "id")}} 118 | > {{.RespStatus 204}} 119 | > {{end}} 120 | > ``` 121 | 122 | [servemux]: https://tip.golang.org/doc/go1.22#enhanced_routing_patterns 123 | 124 |
125 | 126 |
👨‍💻 Define and invoke custom templates 127 | 128 | > All html files under the template root directory are available to invoke by 129 | > their full path relative to the template root dir starting with `/`: 130 | > 131 | > ```html 132 | > 133 | > Home 134 | > 135 | > {{template "/shared/.head.html" .}} 136 | > 137 | > 138 | > 139 | > {{template "navbar" .}} 140 | > ... 141 | > 142 | > 143 | > ``` 144 |
145 | 146 |
🛡️ XSS safe by default 147 | 148 | > The html/template library automatically escapes user content, so you can rest 149 | > easy from basic XSS attacks. The defacto standard html sanitizer for Go, 150 | > BlueMonday, is available for cases where you need finer grained control. 151 | > 152 | > If you have some html string that you do trust, it's easy to inject if that's 153 | > your intention with the `trustHtml` func. 154 |
155 | 156 |
🎨 Customize the context to provide selected data sources 157 | 158 | > Configure xtemplate to get access to built-in and custom data sources like 159 | > running SQL queries against a database, sending and receiving messages using a 160 | > message streaming client like NATS, read and list files from a local 161 | > directory, reading static config from a key-value store, **or perform any 162 | > action you can define by writing a Go API**, like the common "repository" 163 | > design pattern for example. 164 | > 165 | > Modify `Config` to add built-in or custom `ContextProvider` implementations, 166 | > and they will be made available in the dot context. 167 | > 168 | > Some built-in context providers are listed next: 169 |
170 | 171 |
💽 Database context provider: Execute queries 172 | 173 | > Add the built-in Database Context Provider to run queries using the configured 174 | > Go driver and connection string for your database. (Supports the `sqlite3` 175 | > driver by default, compile with your desired driver to use it.) 176 | > 177 | > ```html 178 | > 183 | > ``` 184 |
185 | 186 |
🗄️ Filesystem context provider: List and read local files 187 | 188 | > Add the built-in Filesystem Context Provider to List and read 189 | > files from the configured directory. 190 | > 191 | > ```html 192 | >

Here are the files: 193 | >

    194 | > {{range .ListFiles "dir/"}} 195 | >
  1. {{.Name}}
  2. 196 | > {{end}} 197 | >
198 | > ``` 199 |
200 | 201 |
💬 NATS context provider: Send and receive messages 202 | 203 | > Add and configure the NATS Context Provider to send messages, use the 204 | > Request-Response pattern, and even send live updates to a client. 205 | > 206 | > ```html 207 | > 208 | > ``` 209 |
210 | 211 |
📤 Optimal asset serving 212 | 213 | > Non-template files in the templates directory are served directly from disk 214 | > with appropriate caching responses, negotiating with the client to serve 215 | > compressed versions. Efficient access to the content hash is available to 216 | > templates for efficient SRI and perfect cache behavior. 217 | > 218 | > If a static file also has .gz, .br, .zip, or .zst copies, they are decoded and 219 | > hashed for consistency on startup, and use the `Accept-Encoding` header to 220 | > negotiate an appropriate `Content-Encoding` with the client and served 221 | > directly from disk. 222 | > 223 | > Templates can efficiently access the static file's precalculated content hash 224 | > to build a ` 3 | 4 | {{$RE := `^manual\.(\d+)\.sql$`}} 5 |

Run a manual migration:

6 | {{range .Migrations.List "."}} 7 | {{$id := atoi (regexReplaceAll $RE .Name "$1")}} 8 | {{if eq $id 0}}{{continue}}{{end}} 9 |
10 | {{end}} 11 | 12 |

Results:

13 | 15 | 16 | {{define "POST /db/run"}} 17 | {{.Req.ParseForm}} 18 | {{$id := atoi (.Req.FormValue "id")}} 19 | {{if eq $id 0}}
  • Invalid id: {{$id}}
  • {{return}}{{end}} 20 | {{$file := printf "manual.%d.sql" $id}} 21 | {{$result := try .Migrations `Read` $file}} 22 | {{if not $result.OK}}
  • Failed to read file {{$file}}: {{$result.Error}}
  • {{return}}{{end}} 23 | {{$stmt := $result.Value}} 24 | {{$result := try .DB `Exec` $stmt}} 25 |
  • {{now | date "2006-01-02 15:04:05"}}: 26 | {{if $result.OK}} 27 | Applied migration {{$id}}. 28 | {{else}} 29 | Migration error: {{$result.Error}} 30 | {{end}} 31 |
  • 32 | {{end}} 33 | -------------------------------------------------------------------------------- /test/templates/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infogulch/xtemplate/4a7e9cb208916e322f6a484fb6b185a1c223f1c2/test/templates/favicon.ico -------------------------------------------------------------------------------- /test/templates/flags/index.html: -------------------------------------------------------------------------------- 1 | 2 |

    a: {{.Flags.Value "a"}} 3 | -------------------------------------------------------------------------------- /test/templates/fs/browse/{filepath...}.html: -------------------------------------------------------------------------------- 1 | 2 | {{$path := .Req.PathValue "filepath"}} 3 | {{if ne $path ""}}

    Go up

    {{else}}{{$path = "."}}{{end}} 4 | {{$result := try .FS "Stat" $path}} 5 | {{if not $result.OK}} 6 |

    Path {{$path}} doesn't exist

    7 | {{.Resp.ReturnStatus 404}} 8 | {{end}} 9 | {{$stat := $result.Value}} 10 | {{if $stat.IsDir}} 11 | File listing for {{$path}} ({{$stat.Mode}} {{printf "%+v" $stat.Sys}}): 12 | 19 | {{else}} 20 | File size: {{$stat.Size}} 21 | {{end}} 22 | -------------------------------------------------------------------------------- /test/templates/fs/openclose.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | You can open a file and close it manually: 4 | 5 | {{$file := .FS.Open "foo.txt"}} 6 | {{$file.Close}} 7 | 8 | Any opened files are automatically closed at the end of the request, but if you 9 | open many files in the same request it may be necessary to close them manually 10 | to conserve resources. 11 | -------------------------------------------------------------------------------- /test/templates/fs/serve.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | You can serve a file by opening it and using .Resp.ServeContent, which discards 4 | any content rendered so far and responds with the contents of the file instead. 5 | 6 | You can still set headers that are added to the response. 7 | {{.Resp.AddHeader "Content-Type" "text/plain; charset=utf-8"}} 8 | 9 | {{$file := .FS.Open "foo.txt"}} 10 | {{$stat := $file.Stat}} 11 | {{.Resp.ServeContent $stat.Name $stat.ModTime $file}} 12 | 13 | Opened files are automatically closed when the request completes. 14 | -------------------------------------------------------------------------------- /test/templates/index{$}.html: -------------------------------------------------------------------------------- 1 | 2 | {{- with $hash := .X.StaticFileHash `/assets/reset.css`}} 3 | 4 | {{- end}} 5 |

    Hello world!

    6 | 7 |
    8 | Navigate to different tests: 9 | 17 |
    18 | -------------------------------------------------------------------------------- /test/templates/nats/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

    This is a fully-functional (albeit very basic) multi-user realtime chat powered by htmx, sse, and nats.

    6 |
    7 | {{block "messageinput" .}}{{end}} 8 | 9 |
    10 |

    Messages:

    11 | 14 | 15 | {{define "SSE /nats/messages"}}{{range .Nats.Subscribe "messages"}}{{$.Flush.SendSSE "" ($.X.Template "listitem" . | toString)}}{{end}}{{end}} 16 | {{define "POST /nats/messages"}}{{.Req.ParseForm}}{{.Nats.Publish "messages" (.Req.FormValue "msg")}}{{template "messageinput" .}}{{end}} 17 | -------------------------------------------------------------------------------- /test/templates/ready.html: -------------------------------------------------------------------------------- 1 | OK -------------------------------------------------------------------------------- /test/templates/routing/.hidden.html: -------------------------------------------------------------------------------- 1 |

    You can't see me

    2 | -------------------------------------------------------------------------------- /test/templates/routing/file.html: -------------------------------------------------------------------------------- 1 | 2 |

    hello! 3 | -------------------------------------------------------------------------------- /test/templates/routing/index.html: -------------------------------------------------------------------------------- 1 | 2 | routing 3 | -------------------------------------------------------------------------------- /test/templates/routing/visible.html: -------------------------------------------------------------------------------- 1 | {{template "/routing/.hidden.html". }} 2 | 3 |

    And now you can

    4 | -------------------------------------------------------------------------------- /test/templates/sse/hotreload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{- define "SSE /reload"}}{{.Block}}data: reload{{printf "\n\n"}}{{end}} 4 | 5 | {{now}} 6 | -------------------------------------------------------------------------------- /test/templates/sse/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
    6 | Contents of this box will be updated in real time 7 | with every SSE message received from the event stream 8 |
    9 | 10 | {{- define "SSE /sse/events"}} 11 | {{- $count := .Req.URL.Query.Get `count` | default `100` | atoi}} 12 | {{- $delay := .Req.URL.Query.Get `delay` | default `100` | atoi}} 13 | {{- range .Flush.Repeat $count }} 14 | data: {{.}}{{printf "\n\n"}}{{ $.Flush.Flush }}{{ $.Flush.Sleep $delay }} 15 | {{- end}} 16 | {{- end}} 17 | -------------------------------------------------------------------------------- /test/tests/assets.hurl: -------------------------------------------------------------------------------- 1 | # no Accept-Encoding should return identity 2 | GET http://localhost:8080/assets/file.txt 3 | 4 | HTTP 200 5 | Content-Type: text/plain; charset=utf-8 6 | Content-Encoding: identity 7 | [Asserts] 8 | body == "testing" 9 | 10 | 11 | # accept gzip should return gzip 12 | GET http://localhost:8080/assets/file.txt 13 | Accept-Encoding: gzip 14 | 15 | HTTP 200 16 | Content-Type: text/plain; charset=utf-8 17 | Content-Encoding: gzip 18 | [Asserts] 19 | body == "testing" 20 | 21 | 22 | # accept gzip or identity should return identity 23 | GET http://localhost:8080/assets/file.txt 24 | Accept-Encoding: gzip, identity 25 | 26 | HTTP 200 27 | Content-Type: text/plain; charset=utf-8 28 | Content-Encoding: identity 29 | 30 | 31 | # accept gzip or identity with 0.09 pref to gzip should return identity 32 | GET http://localhost:8080/assets/file.txt 33 | Accept-Encoding: gzip;q=0.5, identity;q=0.41 34 | 35 | HTTP 200 36 | Content-Type: text/plain; charset=utf-8 37 | Content-Encoding: identity 38 | 39 | 40 | # accept gzip or identity with 0.11 pref to gzip should return gzip 41 | GET http://localhost:8080/assets/file.txt 42 | Accept-Encoding: gzip;q=0.5, identity;q=0.39 43 | 44 | HTTP 200 45 | Content-Type: text/plain; charset=utf-8 46 | Content-Encoding: gzip 47 | 48 | 49 | # accept non existent encoding should return identity 50 | GET http://localhost:8080/assets/file.txt 51 | Accept-Encoding: br 52 | 53 | HTTP 200 54 | Content-Type: text/plain; charset=utf-8 55 | Content-Encoding: identity 56 | 57 | 58 | # Empty file 59 | GET http://localhost:8080/assets/empty.txt 60 | 61 | HTTP 200 62 | Content-Type: text/plain; charset=utf-8 63 | Content-Encoding: identity 64 | [Asserts] 65 | body == "" 66 | 67 | 68 | # CSS file 69 | GET http://localhost:8080/assets/reset.css 70 | Accept-Encoding: gzip 71 | 72 | HTTP 200 73 | Content-Type: text/css; charset=utf-8 74 | Content-Encoding: gzip 75 | 76 | 77 | # CSS file 78 | GET http://localhost:8080/assets/reset.css 79 | Accept-Encoding: gzip, br 80 | 81 | HTTP 200 82 | Content-Type: text/css; charset=utf-8 83 | Content-Encoding: br 84 | Etag: "sha384-5rcfZgbOPW7qvI7_bo9eNa8hclwmmmzNeyvDzZlqI6vAzNwzbmi7PTS4uA15-fJj" 85 | 86 | 87 | # CSS file 88 | GET http://localhost:8080/assets/reset.css?hash=sha384-5rcfZgbOPW7qvI7_bo9eNa8hclwmmmzNeyvDzZlqI6vAzNwzbmi7PTS4uA15-fJj 89 | Accept-Encoding: gzip 90 | 91 | HTTP 200 92 | Content-Type: text/css; charset=utf-8 93 | Content-Encoding: gzip 94 | Etag: "sha384-5rcfZgbOPW7qvI7_bo9eNa8hclwmmmzNeyvDzZlqI6vAzNwzbmi7PTS4uA15-fJj" 95 | Cache-Control: public, max-age=31536000, immutable 96 | 97 | 98 | # CSS file 99 | GET http://localhost:8080/assets/reset.css 100 | If-None-Match: "sha384-5rcfZgbOPW7qvI7_bo9eNa8hclwmmmzNeyvDzZlqI6vAzNwzbmi7PTS4uA15-fJj" 101 | 102 | HTTP 304 103 | Etag: "sha384-5rcfZgbOPW7qvI7_bo9eNa8hclwmmmzNeyvDzZlqI6vAzNwzbmi7PTS4uA15-fJj" 104 | 105 | # Standalone gzip file should not be accessible without its extension 106 | GET http://localhost:8080/assets/standalone 107 | 108 | HTTP 404 109 | 110 | # Standalone gzip file should only be accessible by it's full path 111 | GET http://localhost:8080/assets/standalone.gz 112 | 113 | HTTP 200 114 | Content-Encoding: identity 115 | 116 | # check that index includes integrity attribute and hash parameter 117 | GET http://localhost:8080/ 118 | 119 | HTTP 200 120 | [Asserts] 121 | xpath "string(//link[@rel='stylesheet']/@integrity)" startsWith "sha384-5rcfZ" 122 | xpath "string(//link[@rel='stylesheet']/@href)" contains "?hash=sha384-5rcfZ" 123 | 124 | # get favicon 125 | GET http://localhost:8080/favicon.ico 126 | 127 | HTTP 200 128 | -------------------------------------------------------------------------------- /test/tests/db.hurl: -------------------------------------------------------------------------------- 1 | GET http://localhost:8080/db/manual 2 | 3 | HTTP 200 4 | [Asserts] 5 | body contains "Run a manual migration" 6 | body contains "manual.1.sql (1)" 7 | 8 | 9 | POST http://localhost:8080/db/run 10 | [FormParams] 11 | id: 1 12 | 13 | HTTP 200 14 | [Asserts] 15 | body contains "Applied migration 1." 16 | -------------------------------------------------------------------------------- /test/tests/flags.hurl: -------------------------------------------------------------------------------- 1 | # get kv 2 | GET http://localhost:8080/flags 3 | 4 | HTTP 200 5 | [Asserts] 6 | body contains "a: 1" 7 | -------------------------------------------------------------------------------- /test/tests/fs.hurl: -------------------------------------------------------------------------------- 1 | # reading files from fs 2 | GET http://localhost:8080/fs/browse/ 3 | 4 | HTTP 200 5 | [Asserts] 6 | body contains "" 7 | body contains "listing" 8 | 9 | # reading files from fs 10 | GET http://localhost:8080/fs/browse/subdir/ 11 | 12 | HTTP 200 13 | [Asserts] 14 | body contains "" 15 | body contains "world.txt" 16 | 17 | # serve content 18 | GET http://localhost:8080/fs/serve 19 | 20 | HTTP 200 21 | Content-Type: text/plain; charset=utf-8 22 | [Asserts] 23 | body not contains "doctype" 24 | body contains "bar" 25 | 26 | # openclose 27 | GET http://localhost:8080/fs/openclose 28 | 29 | HTTP 200 30 | -------------------------------------------------------------------------------- /test/tests/nats.hurl.disabled: -------------------------------------------------------------------------------- 1 | GET http://localhost:8080/nats 2 | HTTP 200 3 | [Asserts] 4 | body contains "multi-user realtime chat" 5 | 6 | 7 | GET http://localhost:8080/nats/messages 8 | Accept: text/event-stream 9 | HTTP 200 10 | [Asserts] 11 | duration < 1000 12 | body contains "data: \"
  • hello 1
  • \"\n\n" 13 | 14 | 15 | POST http://localhost:8080/nats/messages 16 | [FormParams] 17 | msg: "hello 1" 18 | HTTP 200 19 | [Asserts] 20 | body == "" 21 | -------------------------------------------------------------------------------- /test/tests/routing.hurl: -------------------------------------------------------------------------------- 1 | # index 2 | GET http://localhost:8080/ 3 | 4 | HTTP 200 5 | [Asserts] 6 | body contains "

    Hello world!" 7 | 8 | # routing 9 | GET http://localhost:8080/routing 10 | 11 | HTTP 200 12 | [Asserts] 13 | body contains "routing" 14 | 15 | GET http://localhost:8080/routing/file 16 | 17 | HTTP 200 18 | [Asserts] 19 | body contains "

    hello!" 20 | 21 | # calling another template 22 | GET http://localhost:8080/routing/visible 23 | 24 | HTTP 200 25 | [Asserts] 26 | body contains "

    You can't see me" 27 | 28 | # nonexistent file should not be routable 29 | GET http://localhost:8080/routing/_hidden.html 30 | 31 | HTTP 404 32 | 33 | 34 | GET http://localhost:8080/routing/_hidden 35 | 36 | HTTP 404 37 | 38 | 39 | # Hidden file should not be routable 40 | GET http://localhost:8080/routing/.hidden.html 41 | 42 | HTTP 404 43 | 44 | 45 | GET http://localhost:8080/routing/.hidden 46 | 47 | HTTP 404 48 | 49 | -------------------------------------------------------------------------------- /test/tests/sse.hurl: -------------------------------------------------------------------------------- 1 | GET http://localhost:8080/sse/test 2 | 3 | HTTP 200 4 | [Asserts] 5 | body contains "sse-connect" 6 | 7 | GET http://localhost:8080/sse/events?count=11&delay=10 8 | Accept: text/event-stream 9 | 10 | HTTP 200 11 | [Asserts] 12 | body contains "data: 10" 13 | --------------------------------------------------------------------------------