├── .github
└── workflows
│ ├── ci.yml
│ ├── golangci-lint.yml
│ └── release.yml
├── .gitignore
├── .goreleaser.yml
├── LICENSE
├── README.md
├── example-config.yml
├── go.mod
├── go.sum
├── internal
├── action
│ ├── action.go
│ └── action_test.go
├── broadcaster
│ ├── broadcaster.go
│ └── broadcaster_test.go
├── cmdrun
│ └── cmdrun.go
├── config
│ ├── config.go
│ └── config_test.go
├── ctxrun
│ ├── ctxrun.go
│ └── ctxrun_test.go
├── debounce
│ ├── debounce.go
│ └── debounce_test.go
├── filereg
│ ├── filereg.go
│ └── filereg_test.go
├── fswalk
│ ├── fswalk.go
│ └── fswalk_test.go
├── log
│ ├── log.go
│ └── log_test.go
├── server
│ ├── server.go
│ ├── server_test.go
│ ├── templates.templ
│ ├── templates_templ.go
│ └── testdata
│ │ ├── empty_expect.html
│ │ ├── empty_input.html
│ │ ├── injectiontarget_expect.html
│ │ ├── injectiontarget_input.html
│ │ ├── no_head_expect.html
│ │ ├── no_head_input.html
│ │ ├── nonhtml_expect.html
│ │ ├── nonhtml_input.txt
│ │ ├── uppercase_body_expect.html
│ │ ├── uppercase_body_input.html
│ │ ├── uppercase_head_expect.html
│ │ └── uppercase_head_input.html
├── statetrack
│ ├── statetrack.go
│ └── statetrack_test.go
├── templgofilereg
│ ├── templgofilereg.go
│ ├── templgofilereg_test.go
│ └── testdata
│ │ ├── original.gocode
│ │ ├── recompile.gocode
│ │ └── refresh.gocode
└── watcher
│ ├── watcher.go
│ └── watcher_test.go
├── logo_color.svg
├── logo_white.svg
├── main.go
├── main_test.go
└── tools.go
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | on: [push, pull_request]
2 | name: CI
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - name: Install Go 1.24.3
8 | uses: actions/setup-go@v5
9 | with:
10 | go-version: "1.24.3"
11 | check-latest: true
12 | - name: Checkout repository
13 | uses: actions/checkout@v4
14 |
15 | # Test and report coverage
16 | - name: Run tests with coverage
17 | run: go test -covermode=atomic -coverprofile=coverage.out ./...
18 |
19 | - name: Upload coverage to Coveralls
20 | uses: coverallsapp/github-action@v2.3.6
21 | with:
22 | github-token: ${{ secrets.GITHUB_TOKEN }}
23 | file: coverage.out
24 |
25 | # Make sure templ generate was executed before commit
26 | - name: Generate templates
27 | run: go run github.com/a-h/templ/cmd/templ@v0.3.865 generate
28 | - name: Check file changes after templ generate
29 | run: |
30 | git diff --exit-code
31 | id: diff_files_after_templ_generate
32 | continue-on-error: true
33 | - name: Fail if changes are detected
34 | if: steps.diff_files_after_templ_generate.outcome == 'failure'
35 | run: |
36 | echo "Detected uncommitted changes after running templ generate." \
37 | "Please regenerate .templ templates and commit changes." && exit 1
38 |
39 | # Try compile
40 | - name: Compile
41 | run: go build -o /dev/null .
42 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | tags:
5 | - v*
6 | branches:
7 | - main
8 | pull_request:
9 | permissions:
10 | contents: read
11 | # Optional: allow read access to pull request. Use with `only-new-issues` option.
12 | # pull-requests: read
13 | jobs:
14 | golangci:
15 | name: lint
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/setup-go@v5
19 | with:
20 | go-version: "1.24.3"
21 | check-latest: true
22 | - uses: actions/checkout@v4
23 | - name: golangci-lint
24 | uses: golangci/golangci-lint-action@v8
25 | with:
26 | version: latest
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: goreleaser
2 |
3 | on:
4 | push:
5 | # Run only on tags
6 | tags:
7 | - "*"
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | goreleaser:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 | with:
19 | fetch-depth: 0
20 | - name: Set up Go
21 | uses: actions/setup-go@v5
22 | with:
23 | go-version: stable
24 | - name: Run GoReleaser
25 | uses: goreleaser/goreleaser-action@v6
26 | with:
27 | distribution: goreleaser
28 | version: "~> v2"
29 | args: release --clean
30 | env:
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 |
--------------------------------------------------------------------------------
/.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 | # Binary for local testing
25 | bin
26 |
27 | # Editors
28 | .idea
29 |
30 | dist/
31 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | # This is an example .goreleaser.yml file with some sensible defaults.
2 | # Make sure to check the documentation at https://goreleaser.com
3 |
4 | # The lines below are called `modelines`. See `:help modeline`
5 | # Feel free to remove those if you don't want/need to use them.
6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
8 |
9 | version: 2
10 |
11 | before:
12 | hooks:
13 | # You may remove this if you don't use go modules.
14 | - go mod tidy
15 | # you may remove this if you don't need go generate
16 | - go generate ./...
17 |
18 | builds:
19 | - id: templier
20 | binary: templier
21 | main: .
22 | env:
23 | - CGO_ENABLED=0
24 | goos:
25 | - linux
26 | - windows
27 | - darwin
28 | goarch:
29 | - amd64
30 | - arm64
31 |
32 | archives:
33 | - format: tar.gz
34 | # this name template makes the OS and Arch compatible with the results of `uname`.
35 | name_template: >-
36 | {{ .ProjectName }}_
37 | {{- title .Os }}_
38 | {{- if eq .Arch "amd64" }}x86_64
39 | {{- else if eq .Arch "386" }}i386
40 | {{- else }}{{ .Arch }}{{ end }}
41 | {{- if .Arm }}v{{ .Arm }}{{ end }}
42 | # use zip for windows archives
43 | format_overrides:
44 | - goos: windows
45 | format: zip
46 |
47 | changelog:
48 | sort: asc
49 | filters:
50 | exclude:
51 | - "^docs:"
52 | - "^test:"
53 | - "^ci:"
54 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Roman Sharkov
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Templiér is a Go web frontend development environment for
13 | [Templ](https://github.com/a-h/templ)
14 |
15 | - Watches your `.templ` files and rebuilds them.
16 | - Watches all non-template files, rebuilds and restarts the server ✨.
17 | - Automatically reloads your browser tabs when the server restarts or templates change.
18 | - Runs [golangci-lint](https://golangci-lint.run/) if enabled.
19 | - Reports all errors directly to all open browser tabs ✨.
20 | - Shuts your server down gracefully.
21 | - Displays application server console logs in the terminal.
22 | - Supports templ's debug mode for fast live reload.
23 | - Avoids reloading when files didn't change by keeping track of checksums.
24 | - Allows arbitrary CLI commands to be defined as [custom watchers](#custom-watchers) ✨.
25 | - example: [Bundle JavaScript](#custom-watcher-example-javascript-bundler)
26 | - example: [Rebuild CSS](#custom-watcher-example-tailwindcss-and-postcss)
27 | - example: [Restart on config change](#custom-watcher-example-reload-on-config-change)
28 |
29 | ## Quick Start
30 |
31 | Install Templiér:
32 |
33 | ```sh
34 | go install github.com/romshark/templier@latest
35 | ```
36 |
37 | Then copy-paste [example-config.yml](https://github.com/romshark/templier/blob/main/example-config.yml) to your project source folder as `templier.yml`, edit to your needs and run:
38 |
39 | ```sh
40 | templier --config ./templier.yml
41 | ```
42 |
43 | ℹ️ Templiér automatically detects `templier.yml` and `templier.yaml` in the directory its running in without the explicit `--config` flag.
44 |
45 | ## How is Templiér different from templ's own watch mode?
46 |
47 | As you may already know, templ supports [live reload](https://templ.guide/commands-and-tools/live-reload)
48 | out of the box using `templ generate --watch --proxy="http://localhost:8080" --cmd="go run ."`,
49 | which is great, but Templiér provides even better developer experience:
50 |
51 | - 🥶 Templiér doesn't become unresponsive when the Go code fails to compile,
52 | instead it prints the compiler error output to the browser tab and keeps watching.
53 | Once you fixed the Go code, Templiér will reload and work as usual with no intervention.
54 | In contrast, templ's watcher needs to be restarted manually.
55 | - 📁 Templiér watches **all** file changes recursively
56 | (except for those that match `app.exclude`), recompiles and restarts the server
57 | (unless prevented by a [custom watcher](#custom-watchers)).
58 | Editing an embedded `.json` file in your app?
59 | Updating go mod? Templiér will notice, rebuild, restart and reload the browser
60 | tab for you automatically!
61 | - 🖥️ Templiér shows Templ, Go compiler and [golangci-lint](https://golangci-lint.run/)
62 | errors (if any), and any errors from [custom watchers](#custom-watchers) in the browser.
63 | Templ's watcher just prints errors to the stdout and continues to display
64 | the last valid state.
65 | - ⚙️ Templiér provides more configuration options (TLS, debounce, exclude globs, etc.).
66 |
67 | ## Custom Watchers 👁️👁️
68 |
69 | Custom configurable watchers allow altering the behavior of Templiér for files
70 | that match any of the `include` globs and they can be used for various use cases
71 | demonstrated below.
72 |
73 | The `requires` option allows overwriting the default behavior:
74 |
75 | - empty field/string: no action, just execute Cmd.
76 | - `reload`: Only reloads all browser tabs.
77 | - `restart`: Restarts the server without rebuilding.
78 | - `rebuild`: Requires the server to be rebuilt and restarted (standard behavior).
79 |
80 | If custom watcher `A` requires `reload` but custom watcher `B` requires `rebuild` then
81 | `rebuild` will be chosen once all custom watchers have finished executing.
82 |
83 | ### Custom Watcher Example: JavaScript Bundler
84 |
85 | The following custom watcher will watch for `.js` file updates and automatically run
86 | the CLI command `npm run js:bundle`, after which all browser tabs will be reloaded
87 | using `requires: reload`. `fail-on-error: true` specifies that if `eslint` or `esbuild`
88 | fail in the process, their error output will be shown directly in the browser.
89 |
90 | ```yaml
91 | custom-watchers:
92 | - name: Bundle JS
93 | cmd: npm run bundle:js
94 | include: ["*.js"]
95 | exclude: ["path/to/your/dist.js"]
96 | fail-on-error: true
97 | debounce:
98 | # reload browser after successful bundling
99 | requires: reload
100 | ```
101 |
102 | The `cmd` above refers to a script defined in `package.json` scripts:
103 |
104 | ```json
105 | "scripts": {
106 | "bundle:js": "eslint . && esbuild --bundle --minify --outfile=./dist.js server/js/bundle.js",
107 | "lint:js": "eslint ."
108 | },
109 | ```
110 |
111 | ### Custom Watcher Example: TailwindCSS and PostCSS
112 |
113 | [TailwindCSS](https://tailwindcss.com/) and [PostCSS](https://postcss.org/) are often
114 | used to simplify CSS styling and a custom watcher enables Templiér to hot-reload the
115 | styles on changes:
116 |
117 | First, configure `postcss.config.js`:
118 |
119 | ```js
120 | module.exports = {
121 | content: [
122 | "./server/**/*.templ", // Include any .templ files
123 | ],
124 | plugins: [require("tailwindcss"), require("autoprefixer")],
125 | };
126 | ```
127 |
128 | and `tailwind.config.js`:
129 |
130 | ```js
131 | /** @type {import('tailwindcss').Config} */
132 | module.exports = {
133 | content: ["./**/*.{html,js,templ}"],
134 | theme: {
135 | extend: {},
136 | },
137 | plugins: [require("tailwindcss"), require("autoprefixer")],
138 | };
139 | ```
140 |
141 | Create a `package.json` file and install all necessary dev-dependencies
142 |
143 | ```sh
144 | npm install tailwindcss postcss postcss-cli autoprefixer --save-dev
145 | ```
146 |
147 | Add the scripts to `package.json` (where `input.css` is your main CSS
148 | file containing your global custom styles and `public/dist.css` is the built CSS
149 | output file that's linked to in your HTML):
150 |
151 | ```json
152 | "scripts": {
153 | "build:css": "postcss ./input.css -o ./public/dist.css",
154 | "watch:css": "tailwindcss -i ./input.css -o ./public/dist.css --watch"
155 | },
156 | ```
157 |
158 | Finally, define a Templiér custom watcher to watch all Templ and CSS files and rebuild:
159 |
160 | ```yaml
161 | - name: Build CSS
162 | cmd: npm run build:css
163 | include: ["*.templ", "input.css"]
164 | exclude: ["path/to/your/dist.css"]
165 | fail-on-error: true
166 | debounce:
167 | requires: reload
168 | ```
169 |
170 | NOTE: if your `dist.css` is embedded, you may need to use `requires: rebuild`.
171 |
172 | ### Custom Watcher Example: Reload on config change.
173 |
174 | Normally, Templiér rebuilds and restarts the server when any file changes (except for
175 | `.templ` and `_templ.txt` files). However, when a config file changes we don't usually
176 | require rebuilding the server. Restarting the server may be sufficient in this case:
177 |
178 | ```yaml
179 | - name: Restart server on config change
180 | cmd: # No command, just restart
181 | include: ["*.toml"] # Any TOML file
182 | exclude:
183 | fail-on-error:
184 | debounce:
185 | requires: restart
186 | ```
187 |
188 | ## How Templiér works
189 |
190 | Templiér acts as a file watcher, proxy server and process manager.
191 | Once Templiér is started, it runs `templ generate --watch` in the background and begins
192 | watching files in the `app.dir-src-root` directory.
193 | On start and on file change, it automatically builds your application server executable
194 | saving it in the OS' temp directory (cleaned up latest before exiting) assuming that
195 | the main package is specified by the `app.dir-cmd` directory.
196 | Custom Go compiler arguments can be specified with `compiler`. Once built, the application server
197 | executable is launched with `app.flags` CLI parameters and the working directory
198 | set to `app.dir-work`. When necessary, the application server process is shut down
199 | gracefully, rebuilt, linted and restarted.
200 | On `.templ` file changes Templiér only tries to compile and lint the server code
201 | without refreshing the page.
202 | On `_templ.txt` file changes Templiér refreshes the page.
203 |
204 | Templiér hosts your application under the URL specified by `templier-host` and proxies
205 | all requests to the application server process that it launched injecting Templiér
206 | JavaScript that opens a websocket connection to Templiér from the browser tab to listen
207 | for events and reload or display necessary status information when necessary.
208 | In the CLI console logs, all Templiér logs are prefixed with 🤖,
209 | while application server logs are displayed without the prefix.
210 |
211 | ## Development
212 |
213 | Run the tests using `go test -race ./...` and use the latest version of
214 | [golangci-lint](https://golangci-lint.run/) to ensure code integrity.
215 |
216 | ### Building
217 |
218 | You can build Templiér using the following command:
219 |
220 | ```sh
221 | go build -o templier ./bin/templier
222 | ```
223 |
224 | If you're adding bin library to your path, you can just execute the binary.
225 |
226 | zsh:
227 |
228 | ```zsh
229 | export PATH=$(pwd)/bin:$PATH
230 | ```
231 |
232 | [fish](https://fishshell.com/):
233 |
234 | ```fish
235 | fish_add_path (pwd)/bin
236 | ```
237 |
238 | ### Important Considerations
239 |
240 | - Templiér currently doesn't support Windows.
241 | - When measuring performance, make sure you're not running against the Templiér proxy
242 | that injects the JavaScript for auto-reloading because it will be slower and should
243 | only be used for development. Instead, use the direct host address of your application
244 | server specified by `app.host` in your `templier.yml` configuration.
245 | - Templiér's JavaScript injection uses the `GET /__templier/events` HTTP endpoint for
246 | its websocket connection. Make sure it doesn't collide with your application's paths.
247 |
--------------------------------------------------------------------------------
/example-config.yml:
--------------------------------------------------------------------------------
1 | # proxy-timeout defines how long to wait for the
2 | # application server process to start when receiving
3 | # connection refused errors while proxying.
4 | proxy-timeout: 10s
5 |
6 | # lint enables golangci-lint when true.
7 | lint: true
8 |
9 | # format enables automatic .templ file formatting when true.
10 | format: true
11 |
12 | # templier-host defines what host address to run Templiér on.
13 | templier-host: "your-application:11000"
14 |
15 | log:
16 | # level allows you to chose from different log levels:
17 | # "" (empty): same as erronly.
18 | # erronly: error logs only.
19 | # verbose: verbose logging of relevant events and timings.
20 | # debug: verbose debug logging.
21 | level: erronly
22 |
23 | # clear-on allows you to specify when, if at all, the console logs should be cleared:
24 | # "" (empty): disables console log clearing.
25 | # "restart": clears console logs only on app server restart.
26 | # "file-change": clears console logs on every file change.
27 | clear-on:
28 |
29 | # print-js-debug-logs enables Templiér debug logs in the browser.
30 | print-js-debug-logs: true
31 |
32 | # debounce defines how long to wait for more file changes
33 | # after the first one occurred before triggering server rebuild and restart.
34 | debounce: 50ms
35 |
36 | # tls can be set to null to serve HTTP instead of HTTPS.
37 | tls:
38 | # tls.cert defines the TLS certificate file path.
39 | cert: ./your-application.crt.pem
40 |
41 | # tls.keys defines the TLS private key file path.
42 | key: ./your-application.key.pem
43 |
44 | # compiler defines the optional Go compiler arguments.
45 | # For more info use `go help build`.
46 | compiler:
47 | # compiler.gcflags provides the -gcflags CLI argument to Go compiler when
48 | # compiling the application server executable.
49 | # example:
50 | #
51 | # gcflags: all=-N -l
52 | #
53 | # the example above is equivalent to calling:
54 | # go build -gcflags "all=-N -l"
55 | gcflags:
56 |
57 | # compiler.ldflags provides the -ldflags CLI argument to Go compiler
58 | # to pass on each go tool link invocation.
59 | # example:
60 | #
61 | # ldflags: -X main.version=1.0.0 -s -w
62 | #
63 | # the example above is equivalent to calling:
64 | # go build -ldflags="-X main.version=1.0.0 -s -w"
65 | ldflags:
66 |
67 | # compiler.asmflags is equivalent to `-asmflags '[pattern=]arg list'`.
68 | asmflags:
69 |
70 | # compiler.trimpath sets `-trimpath` when true.
71 | trimpath:
72 |
73 | # compiler.race sets `-race` when true.
74 | race:
75 |
76 | # compiler.tags lists additional build tags to
77 | # consider satisfied during the build.
78 | # example:
79 | #
80 | # tags: [debug,netgo]
81 | #
82 | # the example above is equivalent to calling:
83 | # go build -tags=debug,netgo
84 | tags:
85 |
86 | # compiler.p sets the number of programs, such as build commands that can be run in
87 | # parallel. The default is GOMAXPROCS, normally the number of CPUs available.
88 | p:
89 |
90 | # msan sets `-msan` when true.
91 | msan:
92 |
93 | # compiler.env passes environment variables to the Go compiler.
94 | env:
95 | CGO_ENABLED: 0
96 |
97 | app:
98 | # app.dir-src-root defines the path to the Go module source root directory.
99 | dir-src-root: ./
100 |
101 | # app.exclude defines glob filter expressions relative to app.dir-src-root
102 | # to match files exluded from watching.
103 | exclude:
104 | - .* # all hidden files and directories
105 | - "*~" # all temporary files with a tilde (fixes jetbrains IDEs save)
106 |
107 | # app.dir-cmd defines the path to the main package directory
108 | # within the app source directory.
109 | dir-cmd: ./cmd/server/
110 |
111 | # app.dir-work defines the path to the workspace directory
112 | # to run the application server executable in.
113 | dir-work: ./
114 |
115 | # app.host defines the host address the application server is running on.
116 | host: https://your-application:12000
117 |
118 | # app.flags defines the CLI arguments as a string provided
119 | # to the application server executable.
120 | flags: -host your-application:12000
121 |
122 | # custom-watchers defines custom file change watchers executing arbitrary commands
123 | # on certain file changes that isn't covered by a standard Templiér setup.
124 | custom-watchers:
125 | - name: "Bundle JS"
126 | # cmd specifies the command to run when a JavaScript or JSX file is changed.
127 | # This is optional and can be left empty since sometimes all you want to do is
128 | # rebuild, or restart or simply reload the browser tabs.
129 | cmd: npm run build
130 |
131 | # include defines that this watcher will watch all JavaScript and JSX files.
132 | include: ["*.js", "*.jsx"]
133 |
134 | exclude: # exclude is optional.
135 |
136 | # fail-on-error specifies that in case cmd returns error code 1 the output
137 | # of the execution should be displayed in the browser, just like
138 | # for example if the Go compiler fails to compile.
139 | fail-on-error: true
140 |
141 | # debounce specifies how long to wait for more file changes
142 | # after the first one occurred before executing cmd.
143 | # Default debounce duration is applied if left empty.
144 | debounce:
145 |
146 | # requires specifies that browser tabs need to be reloaded when a .js or .jsx file
147 | # changed and cmd was successfuly executed, but the server doesn't need to be
148 | # rebuilt or restarted.
149 | # This option accepts the following values:
150 | # - "" (or empty field) = no action, execute cmd and don't do anything else.
151 | # - "reload" = reload all browser tabs.
152 | # - "restart" = restart the server but don't rebuild it.
153 | # - "rebuild" = re-lint, rebuild and restart the server.
154 | requires: reload
155 |
156 | - name: "Restart on config change"
157 | # cmd specifies that no special command needs to be executed since this watcher
158 | # just triggers a server restart.
159 | cmd:
160 |
161 | # include specifies what kind of configuration files need to be watched.
162 | include: ["*.yaml", "*.yml", "*.toml"]
163 |
164 | # exclude specifies what kind of configuration files, that would otherwise
165 | # match `include` to explicitly exclude.
166 | exclude: ["ignore-this.yaml"]
167 |
168 | # fail-on-error doesn't need to be specified when cmd is empty. Default is false.
169 | fail-on-error:
170 |
171 | # debounce specifies default debounce duration.
172 | debounce:
173 |
174 | # requires specifies that when a config file changes the server needs
175 | # to be restarted, but doesn't need to be rebuilt.
176 | requires: restart
177 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/romshark/templier
2 |
3 | go 1.24.3
4 |
5 | require (
6 | github.com/a-h/templ v0.3.865
7 | github.com/andybalholm/brotli v1.1.1
8 | github.com/cespare/xxhash/v2 v2.3.0
9 | github.com/fatih/color v1.18.0
10 | github.com/fsnotify/fsnotify v1.9.0
11 | github.com/gobwas/glob v0.2.3
12 | github.com/gorilla/websocket v1.5.3
13 | github.com/romshark/yamagiconf v1.0.4
14 | github.com/stretchr/testify v1.10.0
15 | golang.org/x/net v0.40.0
16 | golang.org/x/sync v0.14.0
17 | )
18 |
19 | require (
20 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
21 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
22 | github.com/cli/browser v1.3.0 // indirect
23 | github.com/davecgh/go-spew v1.1.1 // indirect
24 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect
25 | github.com/go-playground/locales v0.14.1 // indirect
26 | github.com/go-playground/universal-translator v0.18.1 // indirect
27 | github.com/go-playground/validator/v10 v10.26.0 // indirect
28 | github.com/kr/text v0.2.0 // indirect
29 | github.com/leodido/go-urn v1.4.0 // indirect
30 | github.com/mattn/go-colorable v0.1.14 // indirect
31 | github.com/mattn/go-isatty v0.0.20 // indirect
32 | github.com/natefinch/atomic v1.0.1 // indirect
33 | github.com/pmezard/go-difflib v1.0.0 // indirect
34 | golang.org/x/crypto v0.38.0 // indirect
35 | golang.org/x/mod v0.24.0 // indirect
36 | golang.org/x/sys v0.33.0 // indirect
37 | golang.org/x/text v0.25.0 // indirect
38 | golang.org/x/tools v0.33.0 // indirect
39 | gopkg.in/yaml.v3 v3.0.1 // indirect
40 | )
41 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
2 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
3 | github.com/a-h/templ v0.3.865 h1:nYn5EWm9EiXaDgWcMQaKiKvrydqgxDUtT1+4zU2C43A=
4 | github.com/a-h/templ v0.3.865/go.mod h1:oLBbZVQ6//Q6zpvSMPTuBK0F3qOtBdFBcGRspcT+VNQ=
5 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
6 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
7 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
9 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
10 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
11 | github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
12 | github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
13 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
16 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
17 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
18 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
19 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
20 | github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
21 | github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
22 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
23 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
24 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
25 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
26 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
27 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
28 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
29 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
30 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
31 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
32 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
33 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
34 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
35 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
36 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
37 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
38 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
39 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
40 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
41 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
42 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
43 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
44 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
45 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
46 | github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
47 | github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
50 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
51 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
52 | github.com/romshark/yamagiconf v1.0.4 h1:ykNZjNZ46zXl4XK0LeRhrxw7I7zyaQXsG/qoKDBJz0k=
53 | github.com/romshark/yamagiconf v1.0.4/go.mod h1:oPZXsufYvJI32RJFD3nS7+/im+aPu3+172Q+BCmKyVI=
54 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
55 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
56 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
57 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
58 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
59 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
60 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
61 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
62 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
63 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
64 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
65 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
66 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
67 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
68 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
69 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
70 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
71 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
72 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
74 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
75 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
78 |
--------------------------------------------------------------------------------
/internal/action/action.go:
--------------------------------------------------------------------------------
1 | // Package action provides a simple helper for consolidating custom watcher actions.
2 | package action
3 |
4 | import "sync"
5 |
6 | // Type is an action type.
7 | type Type int8
8 |
9 | const (
10 | // ActionNone requires no rebuild, no restart, no reload.
11 | ActionNone Type = iota
12 |
13 | // ActionReload requires browser tab reload.
14 | ActionReload
15 |
16 | // ActionRestart requires restarting the server.
17 | ActionRestart
18 |
19 | // ActionRebuild requires rebuilding and restarting the server.
20 | ActionRebuild
21 | )
22 |
23 | // SyncStatus action status for concurrent use.
24 | type SyncStatus struct {
25 | lock sync.Mutex
26 | status Type
27 | }
28 |
29 | // Require sets the requirement status to t, if current requirement is a subset.
30 | func (s *SyncStatus) Require(t Type) {
31 | s.lock.Lock()
32 | defer s.lock.Unlock()
33 | if t > s.status {
34 | s.status = t
35 | }
36 | }
37 |
38 | // Load returns the current requirement status.
39 | func (s *SyncStatus) Load() Type {
40 | s.lock.Lock()
41 | defer s.lock.Unlock()
42 | return s.status
43 | }
44 |
--------------------------------------------------------------------------------
/internal/action/action_test.go:
--------------------------------------------------------------------------------
1 | package action_test
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/romshark/templier/internal/action"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestRequire(t *testing.T) {
12 | t.Parallel()
13 |
14 | var s action.SyncStatus
15 | require.Equal(t, action.ActionNone, s.Load())
16 |
17 | s.Require(action.ActionReload)
18 | require.Equal(t, action.ActionReload, s.Load(), "overwrite")
19 |
20 | s.Require(action.ActionRestart)
21 | require.Equal(t, action.ActionRestart, s.Load(), "overwrite")
22 |
23 | s.Require(action.ActionReload)
24 | require.Equal(t, action.ActionRestart, s.Load(), "no overwrite")
25 |
26 | s.Require(action.ActionRebuild)
27 | require.Equal(t, action.ActionRebuild, s.Load(), "overwrite")
28 | }
29 |
--------------------------------------------------------------------------------
/internal/broadcaster/broadcaster.go:
--------------------------------------------------------------------------------
1 | package broadcaster
2 |
3 | import "sync"
4 |
5 | type SignalBroadcaster struct {
6 | lock sync.Mutex
7 | listeners map[chan<- struct{}]struct{}
8 | }
9 |
10 | func NewSignalBroadcaster() *SignalBroadcaster {
11 | return &SignalBroadcaster{listeners: map[chan<- struct{}]struct{}{}}
12 | }
13 |
14 | // Len returns the number of listeners.
15 | func (b *SignalBroadcaster) Len() int {
16 | b.lock.Lock()
17 | defer b.lock.Unlock()
18 | return len(b.listeners)
19 | }
20 |
21 | // AddListener adds channel c to listeners,
22 | // which will be written to once BroadcastNonblock is invoked.
23 | func (b *SignalBroadcaster) AddListener(c chan<- struct{}) {
24 | b.lock.Lock()
25 | defer b.lock.Unlock()
26 | b.listeners[c] = struct{}{}
27 | }
28 |
29 | // RemoveListener removes channel c from the listeners.
30 | func (b *SignalBroadcaster) RemoveListener(c chan<- struct{}) {
31 | b.lock.Lock()
32 | defer b.lock.Unlock()
33 | delete(b.listeners, c)
34 | }
35 |
36 | // BroadcastNonblock writes to all listeners in a non-blocking manner.
37 | // BroadcastNonblock ignores unresponsive listeners.
38 | func (b *SignalBroadcaster) BroadcastNonblock() {
39 | b.lock.Lock()
40 | defer b.lock.Unlock()
41 | for l := range b.listeners {
42 | select {
43 | case l <- struct{}{}:
44 | default: // Ignore unresponsive listeners
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/internal/broadcaster/broadcaster_test.go:
--------------------------------------------------------------------------------
1 | package broadcaster_test
2 |
3 | import (
4 | "sync"
5 | "sync/atomic"
6 | "testing"
7 |
8 | "github.com/romshark/templier/internal/broadcaster"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestBroadcast(t *testing.T) {
14 | t.Parallel()
15 |
16 | b := broadcaster.NewSignalBroadcaster()
17 | require.Equal(t, 0, b.Len())
18 |
19 | var wg sync.WaitGroup
20 | wg.Add(2)
21 | var prepare sync.WaitGroup
22 | prepare.Add(2)
23 | var removed sync.WaitGroup
24 | removed.Add(2)
25 |
26 | var counter atomic.Int32
27 |
28 | go func() {
29 | defer wg.Done()
30 | c := make(chan struct{}, 1)
31 | b.AddListener(c)
32 | prepare.Done()
33 | <-c
34 | counter.Add(1)
35 | b.RemoveListener(c)
36 | removed.Done()
37 | }()
38 |
39 | go func() {
40 | defer wg.Done()
41 | c := make(chan struct{}, 1)
42 | b.AddListener(c)
43 | prepare.Done()
44 | <-c
45 | counter.Add(1)
46 | b.RemoveListener(c)
47 | removed.Done()
48 | }()
49 |
50 | prepare.Wait()
51 | require.Equal(t, 2, b.Len())
52 | b.BroadcastNonblock()
53 | wg.Wait()
54 | require.Equal(t, int32(2), counter.Load())
55 |
56 | removed.Wait()
57 | require.Equal(t, 0, b.Len())
58 | b.BroadcastNonblock()
59 | require.Equal(t, int32(2), counter.Load())
60 | }
61 |
--------------------------------------------------------------------------------
/internal/cmdrun/cmdrun.go:
--------------------------------------------------------------------------------
1 | package cmdrun
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "errors"
8 | "fmt"
9 | "os"
10 | "os/exec"
11 |
12 | "github.com/romshark/templier/internal/log"
13 | "github.com/romshark/templier/internal/statetrack"
14 | )
15 |
16 | var ErrExitCode1 = errors.New("exit code 1")
17 |
18 | // Run runs an arbitrary command and returns (output, ErrExitCode1)
19 | // if it exits with error code 1, otherwise returns the original error.
20 | func Run(
21 | ctx context.Context, workDir string, envVars []string, cmd string, args ...string,
22 | ) (out []byte, err error) {
23 | c := exec.CommandContext(ctx, cmd, args...)
24 | c.Dir = workDir
25 |
26 | if envVars != nil {
27 | c.Env = append(os.Environ(), envVars...)
28 | }
29 |
30 | log.Debugf("running command: %s", c.String())
31 | out, err = c.CombinedOutput()
32 | if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
33 | log.Debugf("running command (pid: %d): exited with exit code 1", c.Process.Pid)
34 | return out, ErrExitCode1
35 | } else if err != nil {
36 | return nil, err
37 | }
38 | return out, nil
39 | }
40 |
41 | // Sh runs an arbitrary shell script and behaves similar to Run.
42 | func Sh(ctx context.Context, workDir string, sh string) (out []byte, err error) {
43 | return Run(ctx, workDir, nil, "sh", "-c", sh)
44 | }
45 |
46 | // RunTemplFmt runs `templ fmt `.
47 | func RunTemplFmt(ctx context.Context, workDir string, path string) error {
48 | cmd := exec.Command("templ", "fmt", "-fail", path)
49 | cmd.Dir = workDir
50 | return cmd.Run()
51 | }
52 |
53 | // RunTemplWatch starts `templ generate --log-level debug --watch` and reads its
54 | // stdout pipe for failure and success logs updating the state accordingly.
55 | // When ctx is canceled the interrupt signal is sent to the watch process
56 | // and graceful shutdown is awaited.
57 | func RunTemplWatch(ctx context.Context, workDir string, st *statetrack.Tracker) error {
58 | // Don't use CommandContext since it will kill the process
59 | // which we don't want. We want the command to finish.
60 | cmd := exec.Command(
61 | "templ", "generate",
62 | "--watch",
63 | "--log-level", "debug",
64 | "--open-browser=false",
65 | // Disable Templ's new native Go watcher to avoid any collisions
66 | // since Templier is already watching .go file changes.
67 | "--watch-pattern", `(.+\.templ$)|(.+_templ\.txt$)`,
68 | )
69 | cmd.Dir = workDir
70 |
71 | stdout, err := cmd.StderrPipe()
72 | if err != nil {
73 | return fmt.Errorf("obtaining stdout pipe: %w", err)
74 | }
75 |
76 | log.Debugf("starting a-h/templ in the background: %s", cmd.String())
77 | if err := cmd.Start(); err != nil {
78 | return fmt.Errorf("starting: %w", err)
79 | }
80 |
81 | done := make(chan error, 1)
82 | go func() {
83 | // Read the command output
84 | scanner := bufio.NewScanner(stdout)
85 | for scanner.Scan() {
86 | b := scanner.Bytes()
87 | log.Debugf("templ: %s", string(b))
88 | switch {
89 | case bytes.HasPrefix(b, bytesPrefixWarning):
90 | st.Set(statetrack.IndexTempl, scanner.Text())
91 | case bytes.HasPrefix(b, bytesPrefixErr):
92 | st.Set(statetrack.IndexTempl, scanner.Text())
93 | case bytes.HasPrefix(b, bytesPrefixErrCleared):
94 | st.Set(statetrack.IndexTempl, "")
95 | }
96 | }
97 | if err := scanner.Err(); err != nil {
98 | log.Errorf("scanning templ watch output: %v", err)
99 | }
100 | done <- cmd.Wait()
101 | }()
102 |
103 | select {
104 | case <-ctx.Done(): // Terminate templ watch gracefully.
105 | if err := cmd.Process.Signal(os.Interrupt); err != nil {
106 | return fmt.Errorf("interrupting templ watch process: %w", err)
107 | }
108 | if err := <-done; err != nil {
109 | return fmt.Errorf("process did not exit cleanly: %w", err)
110 | }
111 | case err := <-done: // Command finished without interruption.
112 | return err
113 | }
114 | return nil
115 | }
116 |
117 | var (
118 | bytesPrefixWarning = []byte(`(!)`)
119 | bytesPrefixErr = []byte(`(✗)`)
120 | bytesPrefixErrCleared = []byte(`(✓) Error cleared`)
121 | )
122 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "bytes"
5 | "debug/buildinfo"
6 | "encoding"
7 | "errors"
8 | "flag"
9 | "fmt"
10 | "net/url"
11 | "os"
12 | "os/exec"
13 | "path/filepath"
14 | "strconv"
15 | "strings"
16 | "time"
17 |
18 | "github.com/romshark/templier/internal/action"
19 | "github.com/romshark/templier/internal/log"
20 |
21 | "github.com/gobwas/glob"
22 | "github.com/romshark/yamagiconf"
23 | )
24 |
25 | const (
26 | Version = "0.10.11"
27 | SupportedTemplVersion = "v0.3.865"
28 | )
29 |
30 | var config Config
31 |
32 | type Config struct {
33 | // Compiler defines optional Go compiler flags
34 | Compiler *ConfigCompiler `yaml:"compiler"`
35 |
36 | App ConfigApp `yaml:"app"`
37 |
38 | // Log specifies logging related configurations.
39 | Log ConfigLog `yaml:"log"`
40 |
41 | // Debounce is the file watcher debounce duration.
42 | Debounce time.Duration `yaml:"debounce"`
43 |
44 | // ProxyTimeout defines for how long the proxy must try retry
45 | // requesting the application server when receiving connection refused error.
46 | ProxyTimeout time.Duration `yaml:"proxy-timeout"`
47 |
48 | // Lint runs golangci-lint before building if enabled.
49 | Lint bool `yaml:"lint"`
50 |
51 | // Format enables running `templ fmt` on `.templ` file changes.
52 | Format bool `yaml:"format"`
53 |
54 | // TemplierHost is the Templiér HTTP server host address.
55 | // Example: "127.0.0.1:9999".
56 | TemplierHost string `yaml:"templier-host" validate:"url,required"`
57 |
58 | // TLS is optional, will serve HTTP instead of HTTPS if nil.
59 | TLS *struct {
60 | Cert string `yaml:"cert" validate:"filepath,required"`
61 | Key string `yaml:"key" validate:"filepath,required"`
62 | } `yaml:"tls"`
63 |
64 | // CustomWatchers defines custom file change watchers.
65 | CustomWatchers []ConfigCustomWatcher `yaml:"custom-watchers"`
66 | }
67 |
68 | type ConfigApp struct {
69 | // DirSrcRoot is the source root directory for the application server.
70 | DirSrcRoot string `yaml:"dir-src-root" validate:"dirpath,required"`
71 |
72 | dirSrcRootAbsolute string `yaml:"-"` // Initialized from DirSrcRoot
73 |
74 | // Exclude defines glob expressions to match files exluded from watching.
75 | Exclude GlobList `yaml:"exclude"`
76 |
77 | // DirCmd is the server cmd directory containing the `main` function.
78 | DirCmd string `yaml:"dir-cmd" validate:"dirpath,required"`
79 |
80 | // DirWork is the working directory to run the application server from.
81 | DirWork string `yaml:"dir-work" validate:"dirpath,required"`
82 |
83 | // Flags are the CLI arguments to be passed to the application server.
84 | Flags SpaceSeparatedList `yaml:"flags"`
85 |
86 | // Host is the application server host address.
87 | // Example: "https://local.example.com:8080"
88 | Host URL `yaml:"host" validate:"required"`
89 | }
90 |
91 | func (c *ConfigApp) DirSrcRootAbsolute() string { return c.dirSrcRootAbsolute }
92 |
93 | func (c *Config) CompilerFlags() []string {
94 | if c.Compiler != nil {
95 | return c.Compiler.flags
96 | }
97 | return nil
98 | }
99 |
100 | func (c *Config) CompilerEnv() []string {
101 | if c.Compiler != nil {
102 | return c.Compiler.env
103 | }
104 | return nil
105 | }
106 |
107 | type ConfigCompiler struct {
108 | // Gcflags is the -gcflags compiler flags to be passed to the go
109 | // compiler when compiling the application server.
110 | Gcflags string `yaml:"gcflags"`
111 |
112 | // Ldflags provides the -ldflags CLI argument to Go compiler
113 | // to pass on each go tool link invocation.
114 | Ldflags string `yaml:"ldflags"`
115 |
116 | // Asmflags is equivalent to `-asmflags '[pattern=]arg list'`.
117 | Asmflags string `yaml:"asmflags"`
118 |
119 | // Tags lists additional build tags to consider satisfied during the build.
120 | Tags []string `yaml:"tags"`
121 |
122 | // Race sets `-race` when true.
123 | Race bool `yaml:"race"`
124 |
125 | // Trimpath sets `-trimpath` when true.
126 | Trimpath bool `yaml:"trimpath"`
127 |
128 | // Msan sets `-msan` when true.
129 | Msan bool `yaml:"msan"`
130 |
131 | // P sets the number of programs, such as build commands that can be run in
132 | // parallel. The default is GOMAXPROCS, normally the number of CPUs available.
133 | P uint32 `yaml:"p"`
134 |
135 | // Env passes environment variables to the Go compiler.
136 | Env map[string]string `yaml:"env"`
137 |
138 | env []string `yaml:"-"` // Initialized from Env.
139 |
140 | flags []string `yaml:"-"` // Initialized from all of the above.
141 | }
142 |
143 | type ConfigLog struct {
144 | // Level accepts either of:
145 | // - "": empty string is the same as "erronly"
146 | // - "erronly": error logs only.
147 | // - "verbose": verbose logging of relevant events.
148 | // - "debug": verbose debug logging.
149 | Level LogLevel `yaml:"level"`
150 |
151 | // ClearOn accepts either of:
152 | // - "": disables console log clearing.
153 | // - "restart": clears console logs only on app server restart.
154 | // - "file-change": clears console logs on every file change.
155 | ClearOn LogClear `yaml:"clear-on"`
156 |
157 | // PrintJSDebugLogs enables Templiér injected javascript
158 | // debug logs in the browser.
159 | PrintJSDebugLogs bool `yaml:"print-js-debug-logs"`
160 | }
161 |
162 | type ConfigCustomWatcher struct {
163 | // Name is the display name for the custom watcher.
164 | Name TrimmedString `yaml:"name"`
165 |
166 | // Include specifies glob expressions for what files to watch.
167 | Include GlobList `yaml:"include"`
168 |
169 | // Exclude specifies glob expressions for what files to ignore
170 | // that would otherwise match `include`.
171 | Exclude GlobList `yaml:"exclude"`
172 |
173 | // Cmd specifies the command to run when an included file changed.
174 | // Cmd will be executed in app.dir-work. This is optional and can be left empty
175 | // since sometimes all you want to do is rebuild & restart or just restart
176 | // the server, such as when a config file changes.
177 | Cmd CmdStr `yaml:"cmd"`
178 |
179 | // FailOnError specifies that in case cmd returns error code 1 the output
180 | // of the execution should be displayed in the browser, just like
181 | // for example if the Go compiler fails to compile.
182 | FailOnError bool `yaml:"fail-on-error"`
183 |
184 | // Debounce defines how long to wait for more file changes
185 | // after the first one occurred before executing cmd.
186 | // Default debounce duration is applied if left empty.
187 | Debounce time.Duration `yaml:"debounce"`
188 |
189 | // Requires defines what action is required when an included file changed.
190 | // Accepts the following options:
191 | //
192 | // - "" (or simply keep the field empty): no action, just execute Cmd.
193 | // - "reload": Requires browser tabs to be reloaded.
194 | // - "restart": Requires the server process to be restarted.
195 | // - "rebuild": Requires the server to be rebuilt and restarted.
196 | //
197 | // This option overwrites regular behavior (for non-templ file changes it's "rebuild")
198 | Requires Requires `yaml:"requires"`
199 | }
200 |
201 | func (w ConfigCustomWatcher) Validate() error {
202 | if w.Name == "" {
203 | return errors.New("custom watcher has no name")
204 | }
205 | if w.Requires == Requires(action.ActionNone) && w.Cmd == "" {
206 | return fmt.Errorf("custom watcher %q requires no action, hence "+
207 | " cmd must not be empty", w.Name)
208 | }
209 | return nil
210 | }
211 |
212 | // TrimmedString removes all leading and trailing white space,
213 | // as defined by Unicode, when parsing from text as TextUnmarshaler.
214 | type TrimmedString string
215 |
216 | var _ encoding.TextUnmarshaler = new(TrimmedString)
217 |
218 | func (t *TrimmedString) UnmarshalText(text []byte) error {
219 | *t = TrimmedString(bytes.TrimSpace(text))
220 | return nil
221 | }
222 |
223 | type LogLevel log.LogLevel
224 |
225 | func (l *LogLevel) UnmarshalText(text []byte) error {
226 | switch string(text) {
227 | case "", "erronly":
228 | *l = LogLevel(log.LogLevelErrOnly)
229 | case "verbose":
230 | *l = LogLevel(log.LogLevelVerbose)
231 | case "debug":
232 | *l = LogLevel(log.LogLevelDebug)
233 | default:
234 | return fmt.Errorf(`invalid log option %q, `+
235 | `use either of: ["" (same as erronly), "erronly", "verbose", "debug"]`,
236 | string(text))
237 | }
238 | return nil
239 | }
240 |
241 | type LogClear int8
242 |
243 | const (
244 | LogClearDisabled LogClear = iota
245 | LogClearOnRestart
246 | LogClearOnFileChange
247 | )
248 |
249 | func (l *LogClear) UnmarshalText(text []byte) error {
250 | switch string(text) {
251 | case "":
252 | *l = LogClearDisabled
253 | case "restart":
254 | *l = LogClearOnRestart
255 | case "file-change":
256 | *l = LogClearOnFileChange
257 | default:
258 | return fmt.Errorf(`invalid clear-on option %q, `+
259 | `use either of: ["" (disable), "restart", "file-change"]`,
260 | string(text))
261 | }
262 | return nil
263 | }
264 |
265 | type Requires action.Type
266 |
267 | func (r *Requires) UnmarshalText(text []byte) error {
268 | switch string(text) {
269 | case "":
270 | *r = Requires(action.ActionNone)
271 | case "reload":
272 | *r = Requires(action.ActionReload)
273 | case "restart":
274 | *r = Requires(action.ActionRestart)
275 | case "rebuild":
276 | *r = Requires(action.ActionRebuild)
277 | default:
278 | return fmt.Errorf(`invalid requires action %q, `+
279 | `use either of: ["" (empty, no action), "reload", "restart", "rebuild"]`,
280 | string(text))
281 | }
282 | return nil
283 | }
284 |
285 | type CmdStr string
286 |
287 | func (c *CmdStr) UnmarshalText(t []byte) error {
288 | *c = CmdStr(bytes.Trim(t, " \t\n\r"))
289 | return nil
290 | }
291 |
292 | // Cmd returns only the command without arguments.
293 | func (c CmdStr) Cmd() string {
294 | if c == "" {
295 | return ""
296 | }
297 | return strings.Fields(string(c))[0]
298 | }
299 |
300 | type URL struct{ URL *url.URL }
301 |
302 | func (u *URL) UnmarshalText(t []byte) error {
303 | x, err := url.Parse(string(t))
304 | if err != nil {
305 | return err
306 | }
307 | u.URL = x
308 | return nil
309 | }
310 |
311 | type GlobList []string
312 |
313 | func (e GlobList) Validate() error {
314 | for i, expr := range config.App.Exclude {
315 | if _, err := glob.Compile(expr); err != nil {
316 | return fmt.Errorf("at index %d: %w", i, err)
317 | }
318 | }
319 | return nil
320 | }
321 |
322 | func PrintVersionInfoAndExit() {
323 | defer os.Exit(0)
324 |
325 | p, err := exec.LookPath(os.Args[0])
326 | if err != nil {
327 | fmt.Printf("resolving executable file path: %v\n", err)
328 | os.Exit(1)
329 | }
330 |
331 | f, err := os.Open(p)
332 | if err != nil {
333 | fmt.Printf("opening executable file %q: %v\n", os.Args[0], err)
334 | os.Exit(1)
335 | }
336 |
337 | info, err := buildinfo.Read(f)
338 | if err != nil {
339 | fmt.Printf("Reading build information: %v\n", err)
340 | }
341 |
342 | fmt.Printf("Templiér v%s\n\n", Version)
343 | fmt.Printf("%v\n", info)
344 | }
345 |
346 | func MustParse() *Config {
347 | var fVersion bool
348 | var fConfigPath string
349 | flag.BoolVar(&fVersion, "version", false, "show version")
350 | flag.StringVar(&fConfigPath, "config", "", "config file path")
351 | flag.Parse()
352 |
353 | log.Debugf("reading config file: %q", fConfigPath)
354 |
355 | if fVersion {
356 | PrintVersionInfoAndExit()
357 | }
358 |
359 | // Set default config.
360 | config.App.DirSrcRoot = "./"
361 | config.App.DirCmd = "./"
362 | config.App.DirWork = "./"
363 | config.Debounce = 50 * time.Millisecond
364 | config.ProxyTimeout = 2 * time.Second
365 | config.Lint = true
366 | config.Format = true
367 | config.Log.Level = LogLevel(log.LogLevelErrOnly)
368 | config.Log.ClearOn = LogClearDisabled
369 | config.Log.PrintJSDebugLogs = false
370 | config.TLS = nil
371 |
372 | if fConfigPath == "" {
373 | // Try to detect config automatically.
374 | if _, err := os.Stat("templier.yml"); err == nil {
375 | fConfigPath = "templier.yml"
376 | } else if _, err := os.Stat("templier.yaml"); err == nil {
377 | fConfigPath = "templier.yaml"
378 | } else {
379 | log.Fatalf("couldn't find config file: templier.yml")
380 | }
381 | }
382 | err := yamagiconf.LoadFile(fConfigPath, &config)
383 | if err != nil {
384 | log.Fatalf("reading config file: %v", err)
385 | }
386 |
387 | // Set default watch debounce
388 | for i := range config.CustomWatchers {
389 | if config.CustomWatchers[i].Debounce == 0 {
390 | config.CustomWatchers[i].Debounce = 50 * time.Millisecond
391 | }
392 | }
393 |
394 | config.App.dirSrcRootAbsolute, err = filepath.Abs(config.App.DirSrcRoot)
395 | if err != nil {
396 | log.Fatalf("getting absolute path for app.dir-src-root: %v", err)
397 | }
398 |
399 | if c := config.Compiler; c != nil {
400 | c.flags = []string{}
401 | if c.Gcflags != "" {
402 | c.flags = append(c.flags, "-gcflags", c.Gcflags)
403 | }
404 | if c.Tags != nil {
405 | c.flags = append(c.flags, "-tags", strings.Join(c.Tags, ","))
406 | }
407 | if c.Ldflags != "" {
408 | c.flags = append(c.flags, "-ldflags", c.Ldflags)
409 | }
410 | if c.Race {
411 | c.flags = append(c.flags, "-race")
412 | }
413 | if c.Msan {
414 | c.flags = append(c.flags, "-msan")
415 | }
416 | if c.Trimpath {
417 | c.flags = append(c.flags, "-trimpath")
418 | }
419 | if c.P != 0 {
420 | c.flags = append(c.flags, "-p", strconv.Itoa(int(c.P)))
421 | }
422 | c.env = make([]string, 0, len(c.Env))
423 | for k, v := range c.Env {
424 | c.env = append(c.env, k+"="+v)
425 | }
426 | }
427 |
428 | log.Debugf("set source directory absolute path: %q", config.App.dirSrcRootAbsolute)
429 | return &config
430 | }
431 |
432 | type SpaceSeparatedList []string
433 |
434 | var _ encoding.TextUnmarshaler = &SpaceSeparatedList{}
435 |
436 | func (l *SpaceSeparatedList) UnmarshalText(t []byte) error {
437 | if string(t) == "" {
438 | return nil
439 | }
440 | *l = strings.Fields(string(t))
441 | return nil
442 | }
443 |
--------------------------------------------------------------------------------
/internal/config/config_test.go:
--------------------------------------------------------------------------------
1 | package config_test
2 |
3 | import (
4 | _ "embed"
5 | "testing"
6 |
7 | "github.com/romshark/templier/internal/config"
8 |
9 | "github.com/romshark/yamagiconf"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestValidateType(t *testing.T) {
14 | t.Parallel()
15 |
16 | err := yamagiconf.ValidateType[config.Config]()
17 | require.NoError(t, err)
18 | }
19 |
20 | func TestSpaceSeparatedList(t *testing.T) {
21 | t.Parallel()
22 |
23 | type TestConfig struct {
24 | List config.SpaceSeparatedList `yaml:"list"`
25 | }
26 |
27 | f := func(t *testing.T, input string, expect config.SpaceSeparatedList) {
28 | t.Helper()
29 | var actual TestConfig
30 | err := yamagiconf.Load(input, &actual)
31 | require.NoError(t, err)
32 | require.Equal(t, expect, actual.List)
33 | }
34 |
35 | // Empty.
36 | f(t, "list:", config.SpaceSeparatedList(nil))
37 | // Empty explicit.
38 | f(t, "list: ''", config.SpaceSeparatedList(nil))
39 | // Single.
40 | f(t, "list: one_item", config.SpaceSeparatedList{"one_item"})
41 | // Spaces.
42 | f(t, "list: -flag value", config.SpaceSeparatedList{"-flag", "value"})
43 | // Multiline.
44 | f(t, "list: |\n first second\n third\tfourth",
45 | config.SpaceSeparatedList{"first", "second", "third", "fourth"})
46 | }
47 |
--------------------------------------------------------------------------------
/internal/ctxrun/ctxrun.go:
--------------------------------------------------------------------------------
1 | // Package ctxrun provides a context-canceling goroutine runner.
2 | package ctxrun
3 |
4 | import (
5 | "context"
6 | "sync"
7 | )
8 |
9 | func New() *Runner { return new(Runner) }
10 |
11 | // Runner runs a goroutine and cancels the context of any previous call to run.
12 | type Runner struct {
13 | lock sync.Mutex
14 | counter uint64
15 | cancel context.CancelFunc
16 | }
17 |
18 | // Go cancels the context of the currently running goroutine (if any) and runs
19 | // fn in a new goroutine without waiting for the previous to return.
20 | func (r *Runner) Go(ctx context.Context, fn func(ctx context.Context)) {
21 | r.lock.Lock()
22 | defer r.lock.Unlock()
23 | r.counter++
24 | id := r.counter
25 |
26 | // Cancel any ongoing task
27 | if r.cancel != nil {
28 | r.cancel()
29 | }
30 |
31 | ctx, cancel := context.WithCancel(ctx)
32 | r.cancel = cancel
33 |
34 | go func() {
35 | defer func() {
36 | // Clear the cancel function once fn returns.
37 | r.lock.Lock()
38 | defer r.lock.Unlock()
39 | if r.counter == id {
40 | // No other run was conducted in the meanwhile.
41 | r.cancel = nil
42 | }
43 | }()
44 |
45 | fn(ctx)
46 | }()
47 | }
48 |
--------------------------------------------------------------------------------
/internal/ctxrun/ctxrun_test.go:
--------------------------------------------------------------------------------
1 | package ctxrun_test
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/romshark/templier/internal/ctxrun"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestRunner(t *testing.T) {
12 | r := ctxrun.New()
13 |
14 | blockFirstGoroutine := make(chan struct{})
15 | ctxErrBefore := make(chan error, 1)
16 | ctxErrAfter := make(chan error)
17 | r.Go(context.Background(), func(ctx context.Context) {
18 | // Will write to buffer and immediately continue.
19 | ctxErrBefore <- ctx.Err()
20 | // Will block until second call to Go and manual unblock
21 | <-blockFirstGoroutine
22 | ctxErrAfter <- ctx.Err()
23 | })
24 |
25 | require.NoError(t, <-ctxErrBefore)
26 |
27 | ctxErrBefore2 := make(chan error, 1)
28 | r.Go(context.Background(), func(ctx context.Context) {
29 | ctxErrBefore2 <- ctx.Err()
30 | })
31 |
32 | // Unblock first goroutine to read its context error.
33 | blockFirstGoroutine <- struct{}{}
34 |
35 | require.NoError(t, <-ctxErrBefore2)
36 | require.Equal(t, context.Canceled, <-ctxErrAfter)
37 | }
38 |
39 | // TestRunnerPassCtx makes sure the context passed to Go is the same
40 | // that's received in the function fn.
41 | func TestRunnerPassCtx(t *testing.T) {
42 | r := ctxrun.New()
43 |
44 | type ctxKey int8
45 | const ctxKeyValue ctxKey = 1
46 |
47 | ctx := context.WithValue(context.Background(), ctxKeyValue, 42)
48 |
49 | ctxValue := make(chan int, 1)
50 | r.Go(ctx, func(ctx context.Context) {
51 | // Will write to buffer and immediately continue.
52 | ctxValue <- ctx.Value(ctxKeyValue).(int)
53 | })
54 |
55 | require.Equal(t, 42, <-ctxValue)
56 | }
57 |
--------------------------------------------------------------------------------
/internal/debounce/debounce.go:
--------------------------------------------------------------------------------
1 | package debounce
2 |
3 | import (
4 | "context"
5 | "sync"
6 | "time"
7 | )
8 |
9 | // NewSync creates a new concurrency-safe debouncer.
10 | func NewSync(duration time.Duration) (
11 | runDebouncer func(ctx context.Context), trigger func(fn func()),
12 | ) {
13 | if duration == 0 {
14 | // Debounce disabled, execute fn immediately.
15 | return func(context.Context) { /*Noop*/ }, func(fn func()) { fn() }
16 | }
17 |
18 | var lock sync.Mutex
19 | var fn func()
20 | ticker := time.NewTicker(duration)
21 | ticker.Stop()
22 |
23 | runDebouncer = func(ctx context.Context) {
24 | for {
25 | select {
26 | case <-ticker.C:
27 | lock.Lock()
28 | ticker.Stop()
29 | fn()
30 | fn = nil
31 | lock.Unlock()
32 | case <-ctx.Done():
33 | return
34 | }
35 | }
36 | }
37 | trigger = func(fnNew func()) {
38 | lock.Lock()
39 | defer lock.Unlock()
40 | ticker.Reset(duration)
41 | fn = fnNew
42 | }
43 | return runDebouncer, trigger
44 | }
45 |
--------------------------------------------------------------------------------
/internal/debounce/debounce_test.go:
--------------------------------------------------------------------------------
1 | package debounce_test
2 |
3 | import (
4 | "context"
5 | "sync"
6 | "sync/atomic"
7 | "testing"
8 | "time"
9 |
10 | "github.com/romshark/templier/internal/debounce"
11 |
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestDebounce(t *testing.T) {
16 | t.Parallel()
17 |
18 | ctx, cancel := context.WithCancel(context.Background())
19 | t.Cleanup(cancel)
20 | runDebouncer, trigger := debounce.NewSync(time.Millisecond)
21 | go runDebouncer(ctx)
22 |
23 | var wg sync.WaitGroup
24 | var counter atomic.Int32
25 |
26 | wg.Add(1)
27 | trigger(func() {
28 | // No need to wg.Done since this will be overwritten by the second trigger
29 | counter.Add(1)
30 | })
31 | trigger(func() { // This cancels the first trigger
32 | defer wg.Done()
33 | counter.Add(1)
34 | })
35 |
36 | wg.Wait()
37 | time.Sleep(10 * time.Millisecond)
38 | require.Equal(t, int32(1), counter.Load())
39 | }
40 |
41 | func TestNoDebounce(t *testing.T) {
42 | t.Parallel()
43 |
44 | ctx, cancel := context.WithCancel(context.Background())
45 | t.Cleanup(cancel)
46 | runDebouncer, trigger := debounce.NewSync(0)
47 | go runDebouncer(ctx)
48 |
49 | var counter atomic.Int32
50 | trigger(func() { counter.Add(1) })
51 | require.Equal(t, int32(1), counter.Load())
52 | }
53 |
--------------------------------------------------------------------------------
/internal/filereg/filereg.go:
--------------------------------------------------------------------------------
1 | package filereg
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "strings"
8 |
9 | "github.com/cespare/xxhash/v2"
10 | )
11 |
12 | // Registry keeps track of given files with their checksum
13 | type Registry struct {
14 | hasher *xxhash.Digest
15 | checksumByPath map[string]uint64
16 | }
17 |
18 | func New() *Registry {
19 | return &Registry{
20 | hasher: xxhash.New(),
21 | checksumByPath: make(map[string]uint64),
22 | }
23 | }
24 |
25 | // Len returns the number of registered files.
26 | func (r *Registry) Len() int {
27 | return len(r.checksumByPath)
28 | }
29 |
30 | // Reset resets the registry removing all records.
31 | func (r *Registry) Reset() {
32 | clear(r.checksumByPath)
33 | }
34 |
35 | // Add returns (true,nil) if filePath either wasn't added or
36 | // existed before but had a different checksum, otherwise returns (false,nil).
37 | func (r *Registry) Add(filePath string) (updated bool, err error) {
38 | file, err := os.Open(filePath)
39 | if err != nil {
40 | return false, fmt.Errorf("opening file: %w", err)
41 | }
42 | defer func() { _ = file.Close() }()
43 |
44 | r.hasher.Reset()
45 | if _, err := io.Copy(r.hasher, file); err != nil {
46 | return false, fmt.Errorf("copying to xxhash: %w", err)
47 | }
48 | checksum := r.hasher.Sum64()
49 |
50 | currentChecksum, ok := r.checksumByPath[filePath]
51 | if !ok {
52 | r.checksumByPath[filePath] = checksum
53 | return true, nil
54 | }
55 | if updated = currentChecksum != checksum; updated {
56 | r.checksumByPath[filePath] = checksum
57 | }
58 | return updated, nil
59 | }
60 |
61 | // Remove removes filePath from the registry, if a record exists.
62 | func (r *Registry) Remove(filePath string) {
63 | delete(r.checksumByPath, filePath)
64 | }
65 |
66 | // RemoveWithPrefix removes all records with the given prefix.
67 | func (r *Registry) RemoveWithPrefix(prefix string) {
68 | for p := range r.checksumByPath {
69 | if strings.HasPrefix(p, prefix) {
70 | delete(r.checksumByPath, p)
71 | }
72 | }
73 | }
74 |
75 | // Get returns (checksum,true) if a file record exists for filePath,
76 | // otherwise returns (0,false).
77 | func (r *Registry) Get(filePath string) (checksum uint64, ok bool) {
78 | s, ok := r.checksumByPath[filePath]
79 | return s, ok
80 | }
81 |
--------------------------------------------------------------------------------
/internal/filereg/filereg_test.go:
--------------------------------------------------------------------------------
1 | package filereg_test
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "testing"
7 |
8 | "github.com/romshark/templier/internal/filereg"
9 |
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestRegistry(t *testing.T) {
14 | t.Parallel()
15 |
16 | base := t.TempDir()
17 | pathFoo := filepath.Join(base, "foo")
18 | pathBar := filepath.Join(base, "bar")
19 |
20 | err := os.WriteFile(pathFoo, []byte("foo1"), 0o644)
21 | require.NoError(t, err)
22 | err = os.WriteFile(pathBar, []byte("bar1"), 0o644)
23 | require.NoError(t, err)
24 |
25 | r := filereg.New()
26 |
27 | { // Make sure foo doesn't exist.
28 | checksum, ok := r.Get(pathFoo)
29 | require.False(t, ok)
30 | require.Zero(t, checksum)
31 | }
32 | { // Make sure bar doesn't exist.
33 | checksum, ok := r.Get(pathBar)
34 | require.False(t, ok)
35 | require.Zero(t, checksum)
36 | }
37 |
38 | { // Register foo.
39 | updated, err := r.Add(pathFoo)
40 | require.True(t, updated)
41 | require.NoError(t, err)
42 | }
43 | { // Register bar.
44 | updated, err := r.Add(pathBar)
45 | require.True(t, updated)
46 | require.NoError(t, err)
47 | }
48 | { // Re-register bar, expect no update
49 | updated, err := r.Add(pathBar)
50 | require.False(t, updated)
51 | require.NoError(t, err)
52 | }
53 |
54 | { // Make sure foo & bar exist and have different checksums.
55 | checksumFoo, ok := r.Get(pathFoo)
56 | require.True(t, ok)
57 | require.NotZero(t, checksumFoo)
58 |
59 | checksumBar, ok := r.Get(pathBar)
60 | require.True(t, ok)
61 | require.NotZero(t, checksumBar)
62 |
63 | require.NotEqual(t, checksumFoo, checksumBar)
64 | }
65 |
66 | { // Change foo and expect it to be updated when re-registering.
67 | err := os.WriteFile(pathFoo, []byte("foo2"), 0o644)
68 | require.NoError(t, err)
69 | updated, err := r.Add(pathFoo)
70 | require.NoError(t, err)
71 | require.True(t, updated)
72 | }
73 |
74 | // Remove both foo & bar and make sure they don't exist anymore.
75 | r.Remove(pathFoo)
76 | r.Remove(pathBar)
77 | {
78 | checksum, ok := r.Get(pathFoo)
79 | require.False(t, ok)
80 | require.Zero(t, checksum)
81 | }
82 | {
83 | checksum, ok := r.Get(pathBar)
84 | require.False(t, ok)
85 | require.Zero(t, checksum)
86 | }
87 | }
88 |
89 | func TestRegistryAddErrFileNotFound(t *testing.T) {
90 | t.Parallel()
91 |
92 | r := filereg.New()
93 | updated, err := r.Add("non-existent_file")
94 | require.False(t, updated)
95 | require.ErrorIs(t, err, os.ErrNotExist)
96 | }
97 |
98 | func TestRegistryReset(t *testing.T) {
99 | t.Parallel()
100 |
101 | base := t.TempDir()
102 | p := filepath.Join(base, "foo")
103 |
104 | err := os.WriteFile(p, []byte("foo"), 0o644)
105 | require.NoError(t, err)
106 |
107 | r := filereg.New()
108 |
109 | require.Equal(t, 0, r.Len())
110 |
111 | updated, err := r.Add(p)
112 | require.True(t, updated)
113 | require.NoError(t, err)
114 |
115 | require.Equal(t, 1, r.Len())
116 |
117 | r.Reset()
118 |
119 | require.Equal(t, 0, r.Len())
120 |
121 | checksum, ok := r.Get(p)
122 | require.False(t, ok)
123 | require.Zero(t, checksum)
124 | }
125 |
126 | func TestRegistryRemoveWithPrefix(t *testing.T) {
127 | t.Parallel()
128 |
129 | base := t.TempDir()
130 | pathFoo := filepath.Join(base, "foo")
131 | pathBar := filepath.Join(base, "bar")
132 |
133 | err := os.WriteFile(pathFoo, []byte("foo"), 0o644)
134 | require.NoError(t, err)
135 |
136 | err = os.WriteFile(pathBar, []byte("bar"), 0o644)
137 | require.NoError(t, err)
138 |
139 | r := filereg.New()
140 |
141 | require.Equal(t, 0, r.Len())
142 |
143 | updated, err := r.Add(pathFoo)
144 | require.True(t, updated)
145 | require.NoError(t, err)
146 |
147 | updated, err = r.Add(pathBar)
148 | require.True(t, updated)
149 | require.NoError(t, err)
150 |
151 | require.Equal(t, 2, r.Len())
152 |
153 | r.RemoveWithPrefix(base)
154 |
155 | require.Equal(t, 0, r.Len())
156 |
157 | {
158 | checksum, ok := r.Get(pathFoo)
159 | require.False(t, ok)
160 | require.Zero(t, checksum)
161 | }
162 | {
163 | checksum, ok := r.Get(pathBar)
164 | require.False(t, ok)
165 | require.Zero(t, checksum)
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/internal/fswalk/fswalk.go:
--------------------------------------------------------------------------------
1 | package fswalk
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | )
7 |
8 | // Files recursively iterates over all files in dir and applies fn to each file.
9 | func Files(dir string, fn func(name string) error) error {
10 | return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
11 | if err != nil {
12 | return err
13 | }
14 | if info.IsDir() {
15 | return nil
16 | }
17 | return fn(path)
18 | })
19 | }
20 |
21 | // Dirs recursively iterates over all directories in dir, including dir itself
22 | // and applies fn to each.
23 | func Dirs(dir string, fn func(name string) error) error {
24 | return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
25 | if err != nil {
26 | return err
27 | }
28 | if !info.IsDir() {
29 | return nil
30 | }
31 | return fn(path)
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/internal/fswalk/fswalk_test.go:
--------------------------------------------------------------------------------
1 | package fswalk_test
2 |
3 | import (
4 | "errors"
5 | "os"
6 | "path/filepath"
7 | "testing"
8 |
9 | "github.com/romshark/templier/internal/fswalk"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func Test(t *testing.T) {
14 | d := t.TempDir()
15 | f1_txt := filepath.Join(d, "f1.txt")
16 | f2_go := filepath.Join(d, "f2.go")
17 | subDir := filepath.Join(d, "subdir")
18 | subDir_f1_txt := filepath.Join(subDir, "f1.txt")
19 | subDir_f2_go := filepath.Join(subDir, "f2.go")
20 | subEmpty := filepath.Join(subDir, "empty")
21 | subSubDir := filepath.Join(subDir, "subsubdir")
22 | subSubDir_f1_txt := filepath.Join(subSubDir, "f1.txt")
23 | subSubDir_f2_go := filepath.Join(subSubDir, "f2.go")
24 | subSubSubEmpty := filepath.Join(subSubDir, "empty")
25 |
26 | WriteFile(t, f1_txt, "f1_txt")
27 | WriteFile(t, f2_go, "f2_go")
28 | WriteFile(t, subDir_f1_txt, "subDir_f1_txt")
29 | WriteFile(t, subDir_f2_go, "subDir_f2_go")
30 | WriteFile(t, subSubDir_f1_txt, "subSubDir_f1_txt")
31 | WriteFile(t, subSubDir_f2_go, "subSubDir_f2_go")
32 |
33 | require.NoError(t, os.MkdirAll(subEmpty, 0o777))
34 | require.NoError(t, os.MkdirAll(subSubSubEmpty, 0o777))
35 |
36 | t.Run("Files", func(t *testing.T) {
37 | f := func(t *testing.T, dir string, expect ...string) {
38 | t.Helper()
39 | actual := []string{}
40 | err := fswalk.Files(dir, func(name string) error {
41 | actual = append(actual, name)
42 | return nil
43 | })
44 | require.NoError(t, err)
45 | require.Equal(t, expect, actual)
46 | }
47 |
48 | f(t, d,
49 | f1_txt, f2_go,
50 | subDir_f1_txt, subDir_f2_go,
51 | subSubDir_f1_txt, subSubDir_f2_go)
52 |
53 | f(t, subDir,
54 | subDir_f1_txt, subDir_f2_go,
55 | subSubDir_f1_txt, subSubDir_f2_go)
56 |
57 | f(t, subSubDir,
58 | subSubDir_f1_txt, subSubDir_f2_go)
59 | })
60 |
61 | t.Run("Dirs", func(t *testing.T) {
62 | f := func(t *testing.T, dir string, expect ...string) {
63 | t.Helper()
64 | actual := []string{}
65 | err := fswalk.Dirs(dir, func(name string) error {
66 | actual = append(actual, name)
67 | return nil
68 | })
69 | require.NoError(t, err)
70 | require.Equal(t, expect, actual)
71 | }
72 |
73 | f(t, d,
74 | d, subDir, subEmpty, subSubDir, subSubSubEmpty)
75 |
76 | f(t, subDir,
77 | subDir, subEmpty, subSubDir, subSubSubEmpty)
78 |
79 | f(t, subSubDir,
80 | subSubDir, subSubSubEmpty)
81 | })
82 | }
83 |
84 | func TestFilesFnErr(t *testing.T) {
85 | d := t.TempDir()
86 |
87 | WriteFile(t, filepath.Join(d, "file.txt"), "")
88 | WriteFile(t, filepath.Join(d, "subdir", "file.txt"), "")
89 |
90 | ErrTest := errors.New("test error")
91 |
92 | counter := 0
93 | err := fswalk.Files(d, func(name string) error {
94 | counter++
95 | return ErrTest
96 | })
97 |
98 | require.ErrorIs(t, err, ErrTest)
99 | require.Equal(t, 1, counter)
100 | }
101 |
102 | func WriteFile(t *testing.T, path, data string) {
103 | t.Helper()
104 | err := os.MkdirAll(filepath.Dir(path), 0o777)
105 | require.NoError(t, err)
106 | err = os.WriteFile(path, []byte(data), 0o600)
107 | require.NoError(t, err)
108 | }
109 |
--------------------------------------------------------------------------------
/internal/log/log.go:
--------------------------------------------------------------------------------
1 | package log
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | "os/exec"
8 | "runtime"
9 | "sync"
10 | "sync/atomic"
11 | "time"
12 |
13 | "github.com/fatih/color"
14 | "github.com/fsnotify/fsnotify"
15 | )
16 |
17 | var (
18 | lock sync.Mutex
19 | out io.Writer
20 | level atomic.Int32
21 |
22 | fBlueUnderline = color.New(color.FgBlue, color.Underline)
23 | fBlue = color.New(color.FgBlue)
24 | fGreen = color.New(color.FgGreen, color.Bold)
25 | fRed = color.New(color.FgHiRed, color.Bold)
26 | fYellow = color.New(color.FgHiYellow, color.Bold)
27 | )
28 |
29 | const LinePrefix = "🤖 "
30 |
31 | // ClearLogs clears the console.
32 | func ClearLogs() {
33 | switch runtime.GOOS {
34 | case "linux", "darwin":
35 | cmd := exec.Command("clear")
36 | cmd.Stdout = os.Stdout
37 | _ = cmd.Run() // Ignore errors, we don't care if it fails.
38 | case "windows":
39 | cmd := exec.Command("cmd", "/c", "cls")
40 | cmd.Stdout = os.Stdout
41 | _ = cmd.Run() // Ignore errors, we don't care if it fails.
42 | }
43 | }
44 |
45 | func init() {
46 | out = os.Stdout
47 | }
48 |
49 | type LogLevel int32
50 |
51 | const (
52 | LogLevelErrOnly LogLevel = 0
53 | LogLevelVerbose LogLevel = 1
54 | LogLevelDebug LogLevel = 2
55 | )
56 |
57 | const TimeFormat = "3:04:05.000 PM"
58 |
59 | func SetLogLevel(l LogLevel) { level.Store(int32(l)) }
60 |
61 | func Level() LogLevel { return LogLevel(level.Load()) }
62 |
63 | // TemplierStarted prints the Templiér started log to console.
64 | func TemplierStarted(baseURL string) {
65 | if Level() < LogLevelVerbose {
66 | return
67 | }
68 | lock.Lock()
69 | defer lock.Unlock()
70 | _, _ = fmt.Fprint(out, LinePrefix)
71 | if Level() >= LogLevelDebug {
72 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
73 | _, _ = fmt.Fprint(out, " INFO: ")
74 | }
75 | _, _ = fmt.Fprint(out, "Templiér ")
76 | _, _ = fGreen.Fprint(out, "started")
77 | _, _ = fmt.Fprint(out, " on ")
78 | _, _ = fBlueUnderline.Fprintln(out, baseURL)
79 | }
80 |
81 | // TemplierRestartingServer prints the server restart trigger log to console.
82 | func TemplierRestartingServer(cmdServerPath string) {
83 | if Level() < LogLevelVerbose {
84 | return
85 | }
86 | lock.Lock()
87 | defer lock.Unlock()
88 | _, _ = fmt.Fprint(out, LinePrefix)
89 | if Level() >= LogLevelDebug {
90 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
91 | _, _ = fmt.Fprint(out, " INFO: ")
92 | }
93 | _, _ = fmt.Fprint(out, "restarting ")
94 | _, _ = fGreen.Fprintln(out, cmdServerPath)
95 | }
96 |
97 | // TemplierFileChange prints a file change log to console.
98 | func TemplierFileChange(e fsnotify.Event) {
99 | if Level() < LogLevelVerbose {
100 | return
101 | }
102 | lock.Lock()
103 | defer lock.Unlock()
104 | _, _ = fmt.Fprint(out, LinePrefix)
105 | if Level() >= LogLevelDebug {
106 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
107 | _, _ = fmt.Fprint(out, " INFO: ")
108 | }
109 | _, _ = fmt.Fprint(out, "file ")
110 | _, _ = fmt.Fprint(out, fileOpStr(e.Op))
111 | _, _ = fmt.Fprint(out, ": ")
112 | _, _ = fBlueUnderline.Fprintln(out, e.Name)
113 | }
114 |
115 | // Debugf prints an info line to console.
116 | func Debugf(f string, v ...any) {
117 | if Level() < LogLevelDebug {
118 | return
119 | }
120 | lock.Lock()
121 | defer lock.Unlock()
122 | _, _ = fmt.Fprint(out, LinePrefix)
123 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
124 | _, _ = fmt.Fprint(out, " DEBUG: ")
125 | _, _ = fmt.Fprintf(out, f, v...)
126 | _, _ = fmt.Fprintln(out, "")
127 | }
128 |
129 | // WarnUnsupportedTemplVersion prints a warning line to console
130 | // about the currently installed templ version not matching the templ version
131 | // that the installed version of Templier supports.
132 | func WarnUnsupportedTemplVersion(
133 | templierVersion, supportedTemplVersion, currentTemplVersion string,
134 | ) {
135 | lock.Lock()
136 | defer lock.Unlock()
137 | _, _ = fmt.Fprint(out, LinePrefix)
138 | _, _ = fYellow.Fprint(out, " WARNING: ")
139 | _, _ = fmt.Fprint(out, "Templier ")
140 | _, _ = fGreen.Fprintf(out, "v%s", templierVersion)
141 | _, _ = fmt.Fprint(out, " is optimized to work with templ ")
142 | _, _ = fGreen.Fprintf(out, "%s. ", supportedTemplVersion)
143 | _, _ = fmt.Fprint(out, "You're using templ ")
144 | _, _ = fGreen.Fprint(out, currentTemplVersion)
145 | _, _ = fmt.Fprintln(out, ". This can lead to unexpected behavior!")
146 | }
147 |
148 | // Infof prints an info line to console.
149 | func Infof(f string, v ...any) {
150 | if Level() < LogLevelVerbose {
151 | return
152 | }
153 | lock.Lock()
154 | defer lock.Unlock()
155 | _, _ = fmt.Fprint(out, LinePrefix)
156 | if Level() >= LogLevelDebug {
157 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
158 | _, _ = fmt.Fprint(out, " INFO: ")
159 | }
160 | _, _ = fmt.Fprintf(out, f, v...)
161 | _, _ = fmt.Fprintln(out, "")
162 | }
163 |
164 | // Errorf prints an error line to console.
165 | func Error(msg string) {
166 | lock.Lock()
167 | defer lock.Unlock()
168 | _, _ = fmt.Fprint(out, LinePrefix)
169 | if Level() >= LogLevelDebug {
170 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
171 | _, _ = fmt.Fprint(out, " ")
172 | }
173 | _, _ = fRed.Fprint(out, "ERR: ")
174 | _, _ = fmt.Fprint(out, msg)
175 | _, _ = fmt.Fprintln(out, "")
176 | }
177 |
178 | // Errorf is similar to Error but with formatting.
179 | func Errorf(f string, v ...any) {
180 | lock.Lock()
181 | defer lock.Unlock()
182 | _, _ = fmt.Fprint(out, LinePrefix)
183 | if Level() >= LogLevelDebug {
184 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
185 | _, _ = fmt.Fprint(out, " ")
186 | }
187 | _, _ = fRed.Fprint(out, "ERR: ")
188 | _, _ = fmt.Fprintf(out, f, v...)
189 | _, _ = fmt.Fprintln(out, "")
190 | }
191 |
192 | // FatalCmdNotAvailable prints an error line to console about
193 | // a cmd that's required for Templiér to run not being available
194 | // and exits process with error code 1.
195 | func FatalCmdNotAvailable(cmd, helpURL string) {
196 | lock.Lock()
197 | defer lock.Unlock()
198 | _, _ = fmt.Fprint(out, LinePrefix)
199 | if Level() >= LogLevelDebug {
200 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
201 | _, _ = fmt.Fprint(out, " ")
202 | }
203 | _, _ = fRed.Fprint(out, "ERR: ")
204 | _, _ = fmt.Fprint(out, "it appears ")
205 | _, _ = fGreen.Fprint(out, cmd)
206 | _, _ = fmt.Fprintf(out, " isn't installed on your system or is not in your PATH.\n See:")
207 | _, _ = fBlueUnderline.Fprint(out, helpURL)
208 | _, _ = fmt.Fprintln(out, "")
209 | os.Exit(1)
210 | }
211 |
212 | // FatalCustomWatcherCmdNotAvailable prints an error line to console about
213 | // a cmd that's required for a custom watcher to run not being available
214 | // and exits process with error code 1.
215 | func FatalCustomWatcherCmdNotAvailable(cmd, customWatcherName string) {
216 | lock.Lock()
217 | defer lock.Unlock()
218 | _, _ = fmt.Fprint(out, LinePrefix)
219 | if Level() >= LogLevelDebug {
220 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
221 | _, _ = fmt.Fprint(out, " ")
222 | }
223 | _, _ = fRed.Fprint(out, "ERR: ")
224 | _, _ = fmt.Fprint(out, "it appears ")
225 | _, _ = fGreen.Fprint(out, cmd)
226 | _, _ = fmt.Fprintf(out, " isn't installed on your system or is not in your PATH.\n")
227 | _, _ = fmt.Fprint(out, "This command is required by custom watcher ")
228 | _, _ = fBlue.Fprint(out, customWatcherName)
229 | _, _ = fmt.Fprintln(out, ".")
230 | _, _ = fmt.Fprintln(out, "")
231 | os.Exit(1)
232 | }
233 |
234 | // Fatalf prints an error line to console and exits process with error code 1.
235 | func Fatalf(f string, v ...any) {
236 | lock.Lock()
237 | defer lock.Unlock()
238 | _, _ = fmt.Fprint(out, LinePrefix)
239 | if Level() >= LogLevelDebug {
240 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
241 | _, _ = fmt.Fprint(out, " ")
242 | }
243 | _, _ = fRed.Fprint(out, "FATAL: ")
244 | _, _ = fmt.Fprintf(out, f, v...)
245 | _, _ = fmt.Fprintln(out, "")
246 | os.Exit(1)
247 | }
248 |
249 | // Durf prints an error.
250 | func Durf(msg string, d time.Duration) {
251 | if Level() < LogLevelVerbose {
252 | return
253 | }
254 | lock.Lock()
255 | defer lock.Unlock()
256 | _, _ = fmt.Fprint(out, LinePrefix)
257 | if Level() >= LogLevelDebug {
258 | _, _ = fmt.Fprint(out, time.Now().Format(TimeFormat))
259 | _, _ = fmt.Fprint(out, ": ")
260 | }
261 | _, _ = fmt.Fprint(out, msg)
262 | _, _ = fmt.Fprint(out, " (")
263 | _, _ = fRed.Fprint(out, durStr(d))
264 | _, _ = fmt.Fprintln(out, ")")
265 | }
266 |
267 | func durStr(d time.Duration) string {
268 | switch {
269 | case d < time.Microsecond:
270 | return fmt.Sprintf("%.0fns", float64(d)/float64(time.Nanosecond))
271 | case d < time.Millisecond:
272 | return fmt.Sprintf("%.0fµs", float64(d)/float64(time.Microsecond))
273 | case d < time.Second:
274 | return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond))
275 | case d < time.Minute:
276 | return fmt.Sprintf("%.2fs", float64(d)/float64(time.Second))
277 | }
278 | return d.String()
279 | }
280 |
281 | func fileOpStr(operation fsnotify.Op) string {
282 | switch operation {
283 | case fsnotify.Write:
284 | return "changed"
285 | case fsnotify.Create:
286 | return "created"
287 | case fsnotify.Remove:
288 | return "removed"
289 | case fsnotify.Rename:
290 | return "renamed"
291 | case fsnotify.Chmod:
292 | return "permissions changed"
293 | }
294 | return ""
295 | }
296 |
--------------------------------------------------------------------------------
/internal/log/log_test.go:
--------------------------------------------------------------------------------
1 | package log
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestDurStr(t *testing.T) {
12 | t.Parallel()
13 |
14 | f := func(input time.Duration, expect string) {
15 | t.Helper()
16 | fmt.Println(input.String())
17 | require.Equal(t, expect, durStr(input))
18 | }
19 |
20 | // Don't show decimal places
21 | f(time.Nanosecond, "1ns")
22 | f(999*time.Nanosecond, "999ns")
23 | f(time.Microsecond, "1µs")
24 | f(999*time.Microsecond, "999µs")
25 |
26 | // Show decimal places
27 | f(time.Millisecond, "1.00ms")
28 | f(999*time.Millisecond, "999.00ms")
29 | f(time.Second, "1.00s")
30 | f(59*time.Second, "59.00s")
31 |
32 | // Round up/down
33 | f(time.Microsecond+500*time.Nanosecond, "2µs") // Round up
34 | f(time.Millisecond+999*time.Nanosecond, "1.00ms") // Round down
35 |
36 | // Combine
37 | f(time.Minute, "1m0s")
38 | f(time.Millisecond+560*time.Microsecond, "1.56ms")
39 | f(time.Minute+30*time.Second, "1m30s")
40 | f(10*time.Minute+30*time.Second+500*time.Millisecond, "10m30.5s")
41 | f(60*time.Minute+30*time.Second+500*time.Millisecond, "1h0m30.5s")
42 | }
43 |
--------------------------------------------------------------------------------
/internal/server/server.go:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | import (
4 | "bytes"
5 | "compress/gzip"
6 | "context"
7 | "fmt"
8 | "io"
9 | "math"
10 | "net/http"
11 | "net/http/httputil"
12 | "net/url"
13 | "strconv"
14 | "strings"
15 | "time"
16 |
17 | "github.com/romshark/templier/internal/broadcaster"
18 | "github.com/romshark/templier/internal/config"
19 | "github.com/romshark/templier/internal/log"
20 | "github.com/romshark/templier/internal/statetrack"
21 |
22 | "github.com/andybalholm/brotli"
23 | "github.com/gorilla/websocket"
24 | )
25 |
26 | const (
27 | HeaderHXRequest = "HX-Request"
28 | HeaderTemplSkipModify = "templ-skip-modify"
29 |
30 | // PathProxyEvents defines the path for templier proxy websocket events endpoint.
31 | // This path is very unlikely to collide with any path used by the app server.
32 | PathProxyEvents = "/__templier/events"
33 |
34 | ReverseProxyRetries = 20
35 | ReverseProxyInitialDelay = 100 * time.Millisecond
36 | ReverseProxyBackoffExponent = 1.5
37 | )
38 |
39 | type Config struct {
40 | PrintJSDebugLogs bool
41 | AppHost *url.URL
42 | ProxyTimeout time.Duration
43 | }
44 |
45 | type Server struct {
46 | config *config.Config
47 | httpClient *http.Client
48 | stateTracker *statetrack.Tracker
49 | reload *broadcaster.SignalBroadcaster
50 | jsInjection []byte
51 | webSocketUpgrader websocket.Upgrader
52 | reverseProxy *httputil.ReverseProxy
53 | }
54 |
55 | func MustRenderJSInjection(ctx context.Context, printJSDebugLogs bool) []byte {
56 | var buf bytes.Buffer
57 | err := jsInjection(printJSDebugLogs, PathProxyEvents).Render(ctx, &buf)
58 | if err != nil {
59 | panic(err)
60 | }
61 | return buf.Bytes()
62 | }
63 |
64 | func RenderErrpage(
65 | ctx context.Context, w io.Writer,
66 | title string, content []Report,
67 | printJSDebugLogs bool,
68 | ) error {
69 | return pageError(title, content, printJSDebugLogs, PathProxyEvents).Render(ctx, w)
70 | }
71 |
72 | func New(
73 | httpClient *http.Client,
74 | stateTracker *statetrack.Tracker,
75 | reload *broadcaster.SignalBroadcaster,
76 | config *config.Config,
77 | ) *Server {
78 | jsInjection := MustRenderJSInjection(
79 | context.Background(), config.Log.PrintJSDebugLogs,
80 | )
81 | s := &Server{
82 | config: config,
83 | httpClient: httpClient,
84 | stateTracker: stateTracker,
85 | reload: reload,
86 | jsInjection: jsInjection,
87 | webSocketUpgrader: websocket.Upgrader{
88 | ReadBufferSize: 1024,
89 | WriteBufferSize: 1024,
90 | CheckOrigin: func(r *http.Request) bool { return true }, // Ignore CORS
91 | },
92 | }
93 | s.reverseProxy = httputil.NewSingleHostReverseProxy(config.App.Host.URL)
94 | s.reverseProxy.Transport = &roundTripper{
95 | maxRetries: ReverseProxyRetries,
96 | initialDelay: ReverseProxyInitialDelay,
97 | backoffExponent: ReverseProxyBackoffExponent,
98 | }
99 | s.reverseProxy.ModifyResponse = s.modifyResponse
100 | return s
101 | }
102 |
103 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104 | if r.Method == http.MethodGet && r.URL.Path == PathProxyEvents {
105 | // This request comes from the injected JavaScript,
106 | // Don't forward, handle it instead.
107 | s.handleProxyEvents(w, r)
108 | return
109 | }
110 | if s.stateTracker.ErrIndex() != -1 {
111 | s.handleErrPage(w, r)
112 | return
113 | }
114 | s.reverseProxy.ServeHTTP(w, r)
115 | }
116 |
117 | func (s *Server) modifyResponse(r *http.Response) error {
118 | if r.Header.Get(HeaderTemplSkipModify) == "true" {
119 | return nil
120 | }
121 |
122 | // Set up readers and writers.
123 | newReader := func(in io.Reader) (out io.Reader, err error) {
124 | return in, nil
125 | }
126 | newWriter := func(out io.Writer) io.WriteCloser {
127 | return passthroughWriteCloser{out}
128 | }
129 | switch r.Header.Get("Content-Encoding") {
130 | case "gzip":
131 | newReader = func(in io.Reader) (out io.Reader, err error) {
132 | return gzip.NewReader(in)
133 | }
134 | newWriter = func(out io.Writer) io.WriteCloser {
135 | return gzip.NewWriter(out)
136 | }
137 | case "br":
138 | newReader = func(in io.Reader) (out io.Reader, err error) {
139 | return brotli.NewReader(in), nil
140 | }
141 | newWriter = func(out io.Writer) io.WriteCloser {
142 | return brotli.NewWriter(out)
143 | }
144 | case "":
145 | // No content encoding header found
146 | default:
147 | // Unsupported encoding
148 | }
149 |
150 | // Read the encoded body.
151 | encr, err := newReader(r.Body)
152 | if err != nil {
153 | return err
154 | }
155 | defer func() { _ = r.Body.Close() }()
156 | body, err := io.ReadAll(encr)
157 | if err != nil {
158 | return err
159 | }
160 |
161 | var buf bytes.Buffer
162 | encw := newWriter(&buf)
163 | if strings.HasPrefix(http.DetectContentType(body), "text/html") {
164 | // Inject JavaScript
165 | if err = WriteWithInjection(encw, body, s.jsInjection); err != nil {
166 | return fmt.Errorf("injecting JS: %w", err)
167 | }
168 | } else if _, err := encw.Write(body); err != nil {
169 | return err
170 | }
171 |
172 | if err := encw.Close(); err != nil {
173 | return err
174 | }
175 |
176 | // Update response body.
177 | r.Body = io.NopCloser(&buf)
178 | r.ContentLength = int64(buf.Len())
179 | r.Header.Set("Content-Length", strconv.Itoa(buf.Len()))
180 | return nil
181 | }
182 |
183 | var (
184 | bytesHeadClosingTag = []byte("")
185 | bytesHeadClosingTagUppercase = []byte("")
186 | bytesBodyClosingTag = []byte("
50 | for i, c := range content {
51 | { c.Subject }
52 | { c.Body }
53 | if i+1 != len(content) {
54 |
55 | }
56 | }
57 | @jsInjection(printDebugLogs, wsEventsEndpoint)
58 | ")
187 | bytesBodyClosingTagUppercase = []byte("")
188 | )
189 |
190 | func (s *Server) handleProxyEvents(w http.ResponseWriter, r *http.Request) {
191 | if r.Method != http.MethodGet {
192 | http.Error(w,
193 | "expecting method GET on templier proxy route 'events'",
194 | http.StatusMethodNotAllowed)
195 | return
196 | }
197 |
198 | c, err := s.webSocketUpgrader.Upgrade(w, r, nil)
199 | if err != nil {
200 | log.Errorf("upgrading to websocket: %v", err)
201 | internalErr(w, "upgrading to websocket", err)
202 | return
203 | }
204 | defer func() { _ = c.Close() }()
205 |
206 | notifyStateChange := make(chan struct{})
207 | s.stateTracker.AddListener(notifyStateChange)
208 |
209 | notifyReload := make(chan struct{})
210 | s.reload.AddListener(notifyReload)
211 |
212 | ctx, cancel := context.WithCancel(r.Context())
213 |
214 | defer func() {
215 | s.stateTracker.RemoveListener(notifyStateChange)
216 | s.reload.RemoveListener(notifyReload)
217 | cancel()
218 | log.Debugf("websockets: disconnect (%p); %d active listener(s)",
219 | c, s.stateTracker.NumListeners())
220 | }()
221 |
222 | log.Debugf(
223 | "websockets: upgrade connection (%p): %q; %d active listener(s)",
224 | c, r.URL.String(), s.stateTracker.NumListeners())
225 |
226 | go func() {
227 | defer cancel()
228 | for {
229 | if _, _, err := c.ReadMessage(); err != nil {
230 | break
231 | }
232 | }
233 | }()
234 |
235 | for {
236 | select {
237 | case <-ctx.Done():
238 | return
239 | case <-notifyStateChange:
240 | log.Debugf("websockets: notify state change (%p)", c)
241 | if !writeWSMsg(c, bytesMsgReload) {
242 | return // Disconnect
243 | }
244 | case <-notifyReload:
245 | log.Debugf("websockets: notify reload (%p)", c)
246 | if !writeWSMsg(c, bytesMsgReload) {
247 | return // Disconnect
248 | }
249 | }
250 | }
251 | }
252 |
253 | type passthroughWriteCloser struct {
254 | io.Writer
255 | }
256 |
257 | func (pwc passthroughWriteCloser) Close() error {
258 | return nil
259 | }
260 |
261 | func writeWSMsg(c *websocket.Conn, msg []byte) (ok bool) {
262 | err := c.SetWriteDeadline(time.Now().Add(10 * time.Second))
263 | if err != nil {
264 | return false
265 | }
266 | err = c.WriteMessage(websocket.TextMessage, msg)
267 | return err == nil
268 | }
269 |
270 | var bytesMsgReload = []byte("r")
271 |
272 | type Report struct{ Subject, Body string }
273 |
274 | func (s *Server) newReports() []Report {
275 | if m := s.stateTracker.Get(statetrack.IndexTempl); m != "" {
276 | return []Report{{Subject: "templ", Body: m}}
277 | }
278 | var report []Report
279 | if m := s.stateTracker.Get(statetrack.IndexGolangciLint); m != "" {
280 | report = append(report, Report{Subject: "golangci-lint", Body: m})
281 | }
282 | if m := s.stateTracker.Get(statetrack.IndexGo); m != "" {
283 | report = append(report, Report{Subject: "go", Body: m})
284 | }
285 | if m := s.stateTracker.Get(statetrack.IndexExit); m != "" {
286 | return []Report{{Subject: "process", Body: m}}
287 | }
288 | for i, w := range s.config.CustomWatchers {
289 | if m := s.stateTracker.Get(statetrack.IndexOffsetCustomWatcher + i); m != "" {
290 | report = append(report, Report{Subject: string(w.Name), Body: m})
291 | }
292 | }
293 | return report
294 | }
295 |
296 | func (s *Server) handleErrPage(w http.ResponseWriter, r *http.Request) {
297 | reports := s.newReports()
298 |
299 | title := "1 error"
300 | if len(reports) > 1 {
301 | title = strconv.Itoa(len(reports)) + " errors"
302 | }
303 |
304 | err := RenderErrpage(r.Context(), w, title, reports, s.config.Log.PrintJSDebugLogs)
305 | if err != nil {
306 | panic(fmt.Errorf("rendering errpage: %w", err))
307 | }
308 | }
309 |
310 | func internalErr(w http.ResponseWriter, msg string, err error) {
311 | http.Error(w,
312 | fmt.Sprintf("proxy: %s: %v", msg, err),
313 | http.StatusInternalServerError)
314 | }
315 |
316 | type roundTripper struct {
317 | maxRetries int
318 | initialDelay time.Duration
319 | backoffExponent float64
320 | }
321 |
322 | func (rt *roundTripper) setShouldSkipResponseModificationHeader(
323 | r *http.Request, resp *http.Response,
324 | ) {
325 | // Instruct the modifyResponse function to skip modifying the response if the
326 | // HTTP request has come from HTMX.
327 | if r.Header.Get(HeaderHXRequest) != "true" {
328 | return
329 | }
330 | resp.Header.Set(HeaderTemplSkipModify, "true")
331 | }
332 |
333 | func (rt *roundTripper) RoundTrip(r *http.Request) (*http.Response, error) {
334 | // Read and buffer the body.
335 | var bodyBytes []byte
336 | if r.Body != nil && r.Body != http.NoBody {
337 | var err error
338 | bodyBytes, err = io.ReadAll(r.Body)
339 | if err != nil {
340 | return nil, err
341 | }
342 | _ = r.Body.Close()
343 | }
344 |
345 | // Retry logic.
346 | var resp *http.Response
347 | var err error
348 | for retries := 0; retries < rt.maxRetries; retries++ {
349 | // Clone the request and set the body.
350 | req := r.Clone(r.Context())
351 | if bodyBytes != nil {
352 | req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
353 | }
354 |
355 | // Execute the request.
356 | resp, err = http.DefaultTransport.RoundTrip(req)
357 | if err != nil {
358 | dur := time.Duration(math.Pow(rt.backoffExponent, float64(retries)))
359 | time.Sleep(rt.initialDelay * dur)
360 | continue
361 | }
362 |
363 | rt.setShouldSkipResponseModificationHeader(r, resp)
364 |
365 | return resp, nil
366 | }
367 |
368 | return nil, fmt.Errorf("max retries reached: %q", r.URL.String())
369 | }
370 |
371 | // WriteWithInjection writes to w the body with the injection either at the end of the
372 | // head or at the end of the body. If neither the head nor the body closing tags are
373 | // the injection is written to w before body.
374 | func WriteWithInjection(
375 | w io.Writer, body []byte, injection []byte,
376 | ) error {
377 | if bytes.Contains(body, bytesHeadClosingTag) {
378 | // HEAD closing tag found, replace it.
379 | modified := bytes.Replace(body, bytesHeadClosingTag,
380 | append(injection, bytesHeadClosingTag...), 1)
381 | _, err := w.Write(modified)
382 | return err
383 | } else if bytes.Contains(body, bytesHeadClosingTagUppercase) {
384 | // Uppercase HEAD closing tag found, replace it.
385 | modified := bytes.Replace(body, bytesHeadClosingTagUppercase,
386 | append(injection, bytesHeadClosingTagUppercase...), 1)
387 | _, err := w.Write(modified)
388 | return err
389 | } else if bytes.Contains(body, bytesBodyClosingTag) {
390 | // BODY closing tag found, replace it.
391 | modified := bytes.Replace(body, bytesBodyClosingTag,
392 | append(injection, bytesBodyClosingTag...), 1)
393 | _, err := w.Write(modified)
394 | return err
395 | } else if bytes.Contains(body, bytesBodyClosingTagUppercase) {
396 | // Uppercase BODY closing tag found, replace it.
397 | modified := bytes.Replace(body, bytesBodyClosingTagUppercase,
398 | append(injection, bytesBodyClosingTagUppercase...), 1)
399 | _, err := w.Write(modified)
400 | return err
401 | }
402 |
403 | // Prepend the injection to the body.
404 | if _, err := w.Write(injection); err != nil {
405 | return err
406 | }
407 | _, err := w.Write(body)
408 | return err
409 | }
410 |
--------------------------------------------------------------------------------
/internal/server/server_test.go:
--------------------------------------------------------------------------------
1 | package server_test
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "os"
7 | "testing"
8 |
9 | "github.com/romshark/templier/internal/server"
10 | "github.com/stretchr/testify/require"
11 | "golang.org/x/net/html"
12 | )
13 |
14 | func TestRenderErrPage(t *testing.T) {
15 | var buf bytes.Buffer
16 | err := server.RenderErrpage(
17 | context.Background(), &buf, "test", []server.Report{
18 | {Subject: "Test Subject", Body: "Test Body"},
19 | }, true,
20 | )
21 | require.NoError(t, err)
22 |
23 | _, err = html.Parse(bytes.NewReader(buf.Bytes()))
24 | require.NoError(t, err)
25 | }
26 |
27 | func TestMustRenderJSInjection(t *testing.T) {
28 | jsInjection := server.MustRenderJSInjection(context.Background(), true)
29 | require.NotEmpty(t, jsInjection)
30 |
31 | _, err := html.Parse(bytes.NewReader(jsInjection))
32 | require.NoError(t, err)
33 | }
34 |
35 | func TestInjectInBody(t *testing.T) {
36 | jsInjection := []byte(``)
37 |
38 | f := func(t *testing.T, bodyInputFilePath, expectBodyOutputFilePath string) {
39 | t.Helper()
40 |
41 | body, err := os.ReadFile(bodyInputFilePath)
42 | require.NoError(t, err)
43 |
44 | expected, err := os.ReadFile(expectBodyOutputFilePath)
45 | require.NoError(t, err)
46 |
47 | originalInjectionBytes := string(jsInjection)
48 |
49 | var buf bytes.Buffer
50 | err = server.WriteWithInjection(&buf, []byte(body), jsInjection)
51 | require.NoError(t, err)
52 | actual := buf.String()
53 | require.Equal(t, string(expected), actual)
54 | require.Equal(t, originalInjectionBytes, string(jsInjection),
55 | "mutation of original injection bytes")
56 | }
57 |
58 | f(t,
59 | "testdata/empty_input.html",
60 | "testdata/empty_expect.html",
61 | )
62 | f(t,
63 | "testdata/injectiontarget_input.html",
64 | "testdata/injectiontarget_expect.html",
65 | )
66 | f(t,
67 | "testdata/no_head_input.html",
68 | "testdata/no_head_expect.html",
69 | )
70 | f(t,
71 | "testdata/nonhtml_input.txt",
72 | "testdata/nonhtml_expect.html",
73 | )
74 | f(t,
75 | "testdata/uppercase_body_input.html",
76 | "testdata/uppercase_body_expect.html",
77 | )
78 | f(t,
79 | "testdata/uppercase_head_input.html",
80 | "testdata/uppercase_head_expect.html",
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/internal/server/templates.templ:
--------------------------------------------------------------------------------
1 | package server
2 |
3 | templ pageError(
4 | title string,
5 | content []Report,
6 | printDebugLogs bool, wsEventsEndpoint string,
7 | ) {
8 |
9 |
10 |