├── .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 | GoReportCard 3 | 4 | 5 |
6 |
7 | 8 | 9 | Logo 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("") 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 | 11 | 12 | 13 | { title } 14 | 48 | 49 | 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 | 59 | 60 | } 61 | 62 | templ jsInjection(printDebugLogs bool, wsEventsEndpoint string) { 63 | @templ.JSONScript("_templier__jsInjection", struct { 64 | PrintDebugLogs bool 65 | WSEventsEndpoint string 66 | }{ 67 | PrintDebugLogs: printDebugLogs, 68 | WSEventsEndpoint: wsEventsEndpoint, 69 | }) 70 | 149 | } 150 | -------------------------------------------------------------------------------- /internal/server/templates_templ.go: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.865 4 | package server 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func pageError( 12 | title string, 13 | content []Report, 14 | printDebugLogs bool, wsEventsEndpoint string, 15 | ) templ.Component { 16 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 17 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 18 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 19 | return templ_7745c5c3_CtxErr 20 | } 21 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 22 | if !templ_7745c5c3_IsBuffer { 23 | defer func() { 24 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 25 | if templ_7745c5c3_Err == nil { 26 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 27 | } 28 | }() 29 | } 30 | ctx = templ.InitializeContext(ctx) 31 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 32 | if templ_7745c5c3_Var1 == nil { 33 | templ_7745c5c3_Var1 = templ.NopComponent 34 | } 35 | ctx = templ.ClearChildren(ctx) 36 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") 37 | if templ_7745c5c3_Err != nil { 38 | return templ_7745c5c3_Err 39 | } 40 | var templ_7745c5c3_Var2 string 41 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) 42 | if templ_7745c5c3_Err != nil { 43 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates.templ`, Line: 13, Col: 17} 44 | } 45 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 46 | if templ_7745c5c3_Err != nil { 47 | return templ_7745c5c3_Err 48 | } 49 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") 50 | if templ_7745c5c3_Err != nil { 51 | return templ_7745c5c3_Err 52 | } 53 | for i, c := range content { 54 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") 55 | if templ_7745c5c3_Err != nil { 56 | return templ_7745c5c3_Err 57 | } 58 | var templ_7745c5c3_Var3 string 59 | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject) 60 | if templ_7745c5c3_Err != nil { 61 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates.templ`, Line: 51, Col: 19} 62 | } 63 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 64 | if templ_7745c5c3_Err != nil { 65 | return templ_7745c5c3_Err 66 | } 67 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

")
 68 | 			if templ_7745c5c3_Err != nil {
 69 | 				return templ_7745c5c3_Err
 70 | 			}
 71 | 			var templ_7745c5c3_Var4 string
 72 | 			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(c.Body)
 73 | 			if templ_7745c5c3_Err != nil {
 74 | 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/server/templates.templ`, Line: 52, Col: 17}
 75 | 			}
 76 | 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
 77 | 			if templ_7745c5c3_Err != nil {
 78 | 				return templ_7745c5c3_Err
 79 | 			}
 80 | 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") 81 | if templ_7745c5c3_Err != nil { 82 | return templ_7745c5c3_Err 83 | } 84 | if i+1 != len(content) { 85 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") 86 | if templ_7745c5c3_Err != nil { 87 | return templ_7745c5c3_Err 88 | } 89 | } 90 | } 91 | templ_7745c5c3_Err = jsInjection(printDebugLogs, wsEventsEndpoint).Render(ctx, templ_7745c5c3_Buffer) 92 | if templ_7745c5c3_Err != nil { 93 | return templ_7745c5c3_Err 94 | } 95 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") 96 | if templ_7745c5c3_Err != nil { 97 | return templ_7745c5c3_Err 98 | } 99 | return nil 100 | }) 101 | } 102 | 103 | func jsInjection(printDebugLogs bool, wsEventsEndpoint string) templ.Component { 104 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 105 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 106 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 107 | return templ_7745c5c3_CtxErr 108 | } 109 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 110 | if !templ_7745c5c3_IsBuffer { 111 | defer func() { 112 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 113 | if templ_7745c5c3_Err == nil { 114 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 115 | } 116 | }() 117 | } 118 | ctx = templ.InitializeContext(ctx) 119 | templ_7745c5c3_Var5 := templ.GetChildren(ctx) 120 | if templ_7745c5c3_Var5 == nil { 121 | templ_7745c5c3_Var5 = templ.NopComponent 122 | } 123 | ctx = templ.ClearChildren(ctx) 124 | templ_7745c5c3_Err = templ.JSONScript("_templier__jsInjection", struct { 125 | PrintDebugLogs bool 126 | WSEventsEndpoint string 127 | }{ 128 | PrintDebugLogs: printDebugLogs, 129 | WSEventsEndpoint: wsEventsEndpoint, 130 | }).Render(ctx, templ_7745c5c3_Buffer) 131 | if templ_7745c5c3_Err != nil { 132 | return templ_7745c5c3_Err 133 | } 134 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") 135 | if templ_7745c5c3_Err != nil { 136 | return templ_7745c5c3_Err 137 | } 138 | return nil 139 | }) 140 | } 141 | 142 | var _ = templruntime.GeneratedTemplate 143 | -------------------------------------------------------------------------------- /internal/server/testdata/empty_expect.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/server/testdata/empty_input.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romshark/templier/1be157f475150f408fe4a35d37f4e97aea1f2bcc/internal/server/testdata/empty_input.html -------------------------------------------------------------------------------- /internal/server/testdata/no_head_expect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Test

6 | 7 | 8 | -------------------------------------------------------------------------------- /internal/server/testdata/no_head_input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Test

6 | 7 | 8 | -------------------------------------------------------------------------------- /internal/server/testdata/nonhtml_expect.html: -------------------------------------------------------------------------------- 1 | Just some text -------------------------------------------------------------------------------- /internal/server/testdata/nonhtml_input.txt: -------------------------------------------------------------------------------- 1 | Just some text -------------------------------------------------------------------------------- /internal/server/testdata/uppercase_body_expect.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/server/testdata/uppercase_body_input.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/server/testdata/uppercase_head_expect.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/server/testdata/uppercase_head_input.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /internal/statetrack/statetrack.go: -------------------------------------------------------------------------------- 1 | package statetrack 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/romshark/templier/internal/broadcaster" 7 | ) 8 | 9 | const ( 10 | IndexTempl = 0 11 | IndexGolangciLint = 1 12 | IndexGo = 2 13 | IndexExit = 3 14 | IndexOffsetCustomWatcher = 4 15 | ) 16 | 17 | // ErrIndex returns -1 if there's no error. 18 | func (t *Tracker) ErrIndex() int { 19 | t.lock.Lock() 20 | defer t.lock.Unlock() 21 | for i, e := range t.errMsgBuffer { 22 | if e != "" { 23 | return i 24 | } 25 | } 26 | return -1 27 | } 28 | 29 | type Tracker struct { 30 | // Static layout: 31 | // index 0: templ errors 32 | // index 1: golangci-lint errors 33 | // index 2: go compiler errors 34 | // index 3: process exit code != 0 35 | // index 4-end: custom watcher errors 36 | errMsgBuffer []string 37 | lock sync.Mutex 38 | broadcaster *broadcaster.SignalBroadcaster 39 | } 40 | 41 | func NewTracker(numCustomWatchers int) *Tracker { 42 | return &Tracker{ 43 | errMsgBuffer: make([]string, IndexOffsetCustomWatcher+numCustomWatchers), 44 | broadcaster: broadcaster.NewSignalBroadcaster(), 45 | } 46 | } 47 | 48 | // Get gets the current error message at index. 49 | func (t *Tracker) Get(index int) string { 50 | t.lock.Lock() 51 | defer t.lock.Unlock() 52 | return t.errMsgBuffer[index] 53 | } 54 | 55 | // GetCustomWatcher gets the current error message for custom watcher at index. 56 | func (t *Tracker) GetCustomWatcher(index int) string { 57 | t.lock.Lock() 58 | defer t.lock.Unlock() 59 | return t.errMsgBuffer[IndexOffsetCustomWatcher+index] 60 | } 61 | 62 | // NumListeners returns the number of currently active listeners. 63 | func (s *Tracker) NumListeners() int { 64 | return s.broadcaster.Len() 65 | } 66 | 67 | // AddListener adds a listener channel. 68 | // c will be written struct{}{} to when a state change happens. 69 | func (s *Tracker) AddListener(c chan<- struct{}) { 70 | s.broadcaster.AddListener(c) 71 | } 72 | 73 | // RemoveListener removes a listener channel. 74 | func (s *Tracker) RemoveListener(c chan<- struct{}) { 75 | s.broadcaster.RemoveListener(c) 76 | } 77 | 78 | // Reset resets the state and notifies all listeners. 79 | func (t *Tracker) Reset() { 80 | t.lock.Lock() 81 | defer t.lock.Unlock() 82 | for i := range t.errMsgBuffer { 83 | t.errMsgBuffer[i] = "" 84 | } 85 | t.broadcaster.BroadcastNonblock() 86 | } 87 | 88 | // Set sets or resets (if msg == "") the current error message at index. 89 | func (t *Tracker) Set(index int, msg string) { 90 | t.lock.Lock() 91 | defer t.lock.Unlock() 92 | if msg == t.errMsgBuffer[index] { 93 | return // State didn't change, ignore. 94 | } 95 | t.errMsgBuffer[index] = msg 96 | t.broadcaster.BroadcastNonblock() 97 | } 98 | -------------------------------------------------------------------------------- /internal/statetrack/statetrack_test.go: -------------------------------------------------------------------------------- 1 | package statetrack_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/romshark/templier/internal/statetrack" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestStateListener(t *testing.T) { 13 | t.Parallel() 14 | 15 | s := statetrack.NewTracker(2) 16 | 17 | require.Zero(t, s.GetCustomWatcher(0)) 18 | require.Zero(t, s.GetCustomWatcher(1)) 19 | require.Zero(t, s.Get(statetrack.IndexTempl)) 20 | require.Zero(t, s.Get(statetrack.IndexGolangciLint)) 21 | require.Zero(t, s.Get(statetrack.IndexGo)) 22 | require.Zero(t, s.Get(statetrack.IndexExit)) 23 | 24 | var wg sync.WaitGroup 25 | wg.Add(1) 26 | 27 | c1 := make(chan struct{}, 3) 28 | s.AddListener(c1) 29 | 30 | go func() { 31 | defer wg.Done() 32 | <-c1 33 | <-c1 34 | <-c1 35 | }() 36 | 37 | s.Set(statetrack.IndexGo, "go failed") 38 | s.Set(statetrack.IndexGolangciLint, "golangcilint failed") 39 | s.Set(statetrack.IndexTempl, "templ failed") 40 | s.Set(statetrack.IndexExit, "process exited with code 1") 41 | s.Set(statetrack.IndexOffsetCustomWatcher+1, "custom watcher failed") 42 | 43 | wg.Wait() // Wait for the listener goroutine to receive an update 44 | 45 | require.Equal(t, "", s.GetCustomWatcher(0)) 46 | require.Equal(t, "custom watcher failed", s.GetCustomWatcher(1)) 47 | require.Equal(t, "templ failed", s.Get(statetrack.IndexTempl)) 48 | require.Equal(t, "golangcilint failed", s.Get(statetrack.IndexGolangciLint)) 49 | require.Equal(t, "go failed", s.Get(statetrack.IndexGo)) 50 | require.Equal(t, "process exited with code 1", s.Get(statetrack.IndexExit)) 51 | } 52 | 53 | func TestStateReset(t *testing.T) { 54 | t.Parallel() 55 | 56 | s := statetrack.NewTracker(0) 57 | require.Equal(t, -1, s.ErrIndex()) 58 | 59 | require.Zero(t, s.Get(statetrack.IndexTempl)) 60 | require.Zero(t, s.Get(statetrack.IndexGolangciLint)) 61 | require.Zero(t, s.Get(statetrack.IndexGo)) 62 | require.Zero(t, s.Get(statetrack.IndexExit)) 63 | 64 | s.Set(statetrack.IndexGo, "go failed") 65 | s.Set(statetrack.IndexGolangciLint, "golangcilint failed") 66 | s.Set(statetrack.IndexTempl, "templ failed") 67 | require.Equal(t, 0, s.ErrIndex()) 68 | 69 | s.Reset() 70 | require.Zero(t, s.Get(statetrack.IndexTempl)) 71 | require.Zero(t, s.Get(statetrack.IndexGolangciLint)) 72 | require.Zero(t, s.Get(statetrack.IndexGo)) 73 | require.Zero(t, s.Get(statetrack.IndexExit)) 74 | 75 | require.Equal(t, -1, s.ErrIndex()) 76 | } 77 | 78 | func TestStateNoChange(t *testing.T) { 79 | t.Parallel() 80 | 81 | s := statetrack.NewTracker(0) 82 | require.Equal(t, -1, s.ErrIndex()) 83 | 84 | s.Set(statetrack.IndexGo, "go failed") 85 | s.Set(statetrack.IndexGolangciLint, "golangcilint failed") 86 | require.Equal(t, 1, s.ErrIndex()) 87 | 88 | c := make(chan struct{}, 3) 89 | s.AddListener(c) 90 | 91 | s.Set(statetrack.IndexGo, "go failed") 92 | s.Set(statetrack.IndexGolangciLint, "golangcilint failed") 93 | 94 | require.Len(t, c, 0) 95 | 96 | require.Equal(t, "go failed", s.Get(statetrack.IndexGo)) 97 | require.Equal(t, "golangcilint failed", s.Get(statetrack.IndexGolangciLint)) 98 | } 99 | -------------------------------------------------------------------------------- /internal/templgofilereg/templgofilereg.go: -------------------------------------------------------------------------------- 1 | // templgofilereg solves a very peculiar task. It provides a comparer 2 | // that keeps track of generated `_templ.go` files given to it and 3 | // tells whether the recent changes to it require Templiér to recompile 4 | // and restart the application server because the internal Go code has 5 | // changed. 6 | // 7 | // In dev mode, Templ uses `_templ.txt` files to allow for fast reloads 8 | // that don't require server recompilation, but Templiér can't know when 9 | // to just refresh the tab and when to actually recompile because Templ 10 | // always changes the `_templ.go` file. However, when it only changes 11 | // the text argument to `templruntime.WriteString` we can tell we don't 12 | // need recompilation and a tab refresh is enough. 13 | package templgofilereg 14 | 15 | import ( 16 | "bytes" 17 | "fmt" 18 | "go/ast" 19 | "go/parser" 20 | "go/printer" 21 | "go/token" 22 | ) 23 | 24 | // Comparer compares the generated `_templ.go` that Templ generates 25 | // to its previous version passed to Compare and attempts to detect 26 | // whether a server recompilation is necessary. 27 | type Comparer struct { 28 | buf bytes.Buffer 29 | byName map[string]string // file name -> normalized 30 | } 31 | 32 | func New() *Comparer { return &Comparer{byName: map[string]string{}} } 33 | 34 | func (c *Comparer) printTemplGoFileAST(filePath string) (string, error) { 35 | fset := token.NewFileSet() 36 | 37 | parsed, err := parser.ParseFile(fset, filePath, nil, parser.AllErrors) 38 | if err != nil { 39 | return "", fmt.Errorf("parsing: %w", err) 40 | } 41 | 42 | ast.Inspect(parsed, func(n ast.Node) bool { 43 | call, ok := n.(*ast.CallExpr) 44 | if !ok { 45 | return true 46 | } 47 | sel, ok := call.Fun.(*ast.SelectorExpr) 48 | if !ok { 49 | return true 50 | } 51 | pkg, ok := sel.X.(*ast.Ident) 52 | if ok && pkg.Name == "templruntime" && sel.Sel.Name == "WriteString" { 53 | if len(call.Args) > 2 { 54 | // Reset the string values in calls to `templruntime.WriteString` 55 | // to exclude those from the diff. 56 | // When a _templ.go file changes and the only changes were made to the 57 | // string arguments then no recompilation is necessary. 58 | call.Args[2] = &ast.BasicLit{Kind: token.STRING, Value: `""`} 59 | } 60 | } 61 | return true 62 | }) 63 | c.buf.Reset() 64 | if err = printer.Fprint(&c.buf, token.NewFileSet(), parsed); err != nil { 65 | return "", fmt.Errorf("printing normalized source file: %w", err) 66 | } 67 | return c.buf.String(), nil 68 | } 69 | 70 | // Compare returns recompile=true if the _templ.go file under filePath 71 | // changed in a way that requires the server to be recompiled. 72 | // Otherwise the file only changed the text arguments in calls to 73 | // templruntime.WriteString which means that the change can be reflected by 74 | // _templ.txt fast reload dev file. 75 | // Compare always returns recompile=true if the file is new. 76 | func (c *Comparer) Compare(filePath string) (recompile bool, err error) { 77 | serialized, err := c.printTemplGoFileAST(filePath) 78 | if err != nil { 79 | return false, err 80 | } 81 | 82 | recompile = true // By default, assume the code changed. 83 | if previous := c.byName[filePath]; previous != "" { 84 | recompile = serialized != previous 85 | } 86 | c.byName[filePath] = serialized // Overwrite. 87 | return recompile, nil 88 | } 89 | 90 | // Remove removes the file from the registry. 91 | // No-op if the file isn't registered. 92 | func (c *Comparer) Remove(filePath string) { 93 | delete(c.byName, filePath) 94 | } 95 | -------------------------------------------------------------------------------- /internal/templgofilereg/templgofilereg_test.go: -------------------------------------------------------------------------------- 1 | package templgofilereg_test 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/romshark/templier/internal/templgofilereg" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func overwriteFile(srcPath, destPath string) error { 14 | // Open the source file 15 | srcFile, err := os.Open(srcPath) 16 | if err != nil { 17 | return err 18 | } 19 | defer func() { _ = srcFile.Close() }() 20 | 21 | // Open the destination file with truncation 22 | destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0o644) 23 | if err != nil { 24 | return err 25 | } 26 | defer func() { _ = destFile.Close() }() 27 | 28 | // Copy the contents 29 | _, err = io.Copy(destFile, srcFile) 30 | return err 31 | } 32 | 33 | func TestComparer(t *testing.T) { 34 | t.Parallel() 35 | 36 | testFile := filepath.Join(t.TempDir(), "test_templ.go") 37 | 38 | const pathOriginal = "./testdata/original.gocode" 39 | const pathRefresh = "./testdata/refresh.gocode" 40 | const pathRecompile = "./testdata/recompile.gocode" 41 | 42 | c := templgofilereg.New() 43 | 44 | check := func(inputFilePath string, expectRecompile bool) { 45 | t.Helper() 46 | err := overwriteFile(inputFilePath, testFile) 47 | require.NoError(t, err) 48 | recompile, err := c.Compare(testFile) 49 | require.NoError(t, err) 50 | // Repeated check, no change relative to previous call. 51 | require.Equal(t, expectRecompile, recompile) 52 | } 53 | 54 | check(pathOriginal, true) 55 | 56 | // Repeated check, no change relative to previous call. 57 | check(pathOriginal, false) 58 | 59 | // Only the text arguments changed. 60 | // The Go code remains structurally identical. 61 | check(pathRefresh, false) 62 | 63 | // Repeated check, no change relative to previous call. 64 | check(pathRefresh, false) 65 | 66 | // This change changed the structure of the Go code, not just the text. 67 | check(pathRecompile, true) 68 | 69 | // Repeated check, no change relative to previous call. 70 | check(pathRecompile, false) 71 | 72 | c.Remove(testFile) 73 | c.Remove(testFile) // No-op. 74 | c.Remove("") // No-op. 75 | 76 | // File was removed and is considered new again. 77 | check(pathRecompile, true) 78 | 79 | // Repeated check, no change relative to previous call. 80 | check(pathRecompile, false) 81 | } 82 | 83 | func TestComparerErr(t *testing.T) { 84 | t.Parallel() 85 | 86 | testFile := filepath.Join(t.TempDir(), "test_templ.go") 87 | err := os.WriteFile(testFile, []byte("invalid"), 0o600) 88 | require.NoError(t, err) 89 | 90 | c := templgofilereg.New() 91 | recompile, err := c.Compare(testFile) 92 | require.Zero(t, recompile) 93 | require.Error(t, err) 94 | } 95 | -------------------------------------------------------------------------------- /internal/templgofilereg/testdata/original.gocode: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.857 4 | package main 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func pageMain(content string) templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Static content

") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | var templ_7745c5c3_Var2 string 37 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(content) 38 | if templ_7745c5c3_Err != nil { 39 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `template.templ`, Line: 5, Col: 12} 40 | } 41 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 42 | if templ_7745c5c3_Err != nil { 43 | return templ_7745c5c3_Err 44 | } 45 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") 46 | if templ_7745c5c3_Err != nil { 47 | return templ_7745c5c3_Err 48 | } 49 | return nil 50 | }) 51 | } 52 | 53 | var _ = templruntime.GeneratedTemplate 54 | -------------------------------------------------------------------------------- /internal/templgofilereg/testdata/recompile.gocode: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.857 4 | package main 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func pageMain(content string) templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Static content

") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | var templ_7745c5c3_Var2 string 37 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(content) 38 | if templ_7745c5c3_Err != nil { 39 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `template.templ`, Line: 5, Col: 12} 40 | } 41 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 42 | if templ_7745c5c3_Err != nil { 43 | return templ_7745c5c3_Err 44 | } 45 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") 46 | if templ_7745c5c3_Err != nil { 47 | return templ_7745c5c3_Err 48 | } 49 | var templ_7745c5c3_Var3 string 50 | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(content) 51 | if templ_7745c5c3_Err != nil { 52 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `template.templ`, Line: 5, Col: 27} 53 | } 54 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 55 | if templ_7745c5c3_Err != nil { 56 | return templ_7745c5c3_Err 57 | } 58 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") 59 | if templ_7745c5c3_Err != nil { 60 | return templ_7745c5c3_Err 61 | } 62 | return nil 63 | }) 64 | } 65 | 66 | var _ = templruntime.GeneratedTemplate 67 | -------------------------------------------------------------------------------- /internal/templgofilereg/testdata/refresh.gocode: -------------------------------------------------------------------------------- 1 | // Code generated by templ - DO NOT EDIT. 2 | 3 | // templ: version: v0.3.857 4 | package main 5 | 6 | //lint:file-ignore SA4006 This context is only used if a nested component is present. 7 | 8 | import "github.com/a-h/templ" 9 | import templruntime "github.com/a-h/templ/runtime" 10 | 11 | func pageMain(content string) templ.Component { 12 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 13 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 14 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 15 | return templ_7745c5c3_CtxErr 16 | } 17 | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 18 | if !templ_7745c5c3_IsBuffer { 19 | defer func() { 20 | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 21 | if templ_7745c5c3_Err == nil { 22 | templ_7745c5c3_Err = templ_7745c5c3_BufErr 23 | } 24 | }() 25 | } 26 | ctx = templ.InitializeContext(ctx) 27 | templ_7745c5c3_Var1 := templ.GetChildren(ctx) 28 | if templ_7745c5c3_Var1 == nil { 29 | templ_7745c5c3_Var1 = templ.NopComponent 30 | } 31 | ctx = templ.ClearChildren(ctx) 32 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Static content static change only

") 33 | if templ_7745c5c3_Err != nil { 34 | return templ_7745c5c3_Err 35 | } 36 | var templ_7745c5c3_Var2 string 37 | templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(content) 38 | if templ_7745c5c3_Err != nil { 39 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `template.templ`, Line: 5, Col: 12} 40 | } 41 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) 42 | if templ_7745c5c3_Err != nil { 43 | return templ_7745c5c3_Err 44 | } 45 | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") 46 | if templ_7745c5c3_Err != nil { 47 | return templ_7745c5c3_Err 48 | } 49 | return nil 50 | }) 51 | } 52 | 53 | var _ = templruntime.GeneratedTemplate -------------------------------------------------------------------------------- /internal/watcher/watcher.go: -------------------------------------------------------------------------------- 1 | // Package watcher provides a recursive file watcher implementation 2 | // with support for glob expression based exclusions and event deduplication 3 | // based on xxhash file checksums. 4 | package watcher 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "io/fs" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/romshark/templier/internal/filereg" 17 | "github.com/romshark/templier/internal/fswalk" 18 | "github.com/romshark/templier/internal/log" 19 | 20 | "github.com/fsnotify/fsnotify" 21 | "github.com/gobwas/glob" 22 | ) 23 | 24 | // Watcher is a recursive file watcher. 25 | type Watcher struct { 26 | lock sync.Mutex 27 | runnerStart sync.WaitGroup 28 | baseDir string 29 | fileRegistry *filereg.Registry 30 | watchedDirs map[string]struct{} 31 | exclude map[string]glob.Glob 32 | onChange func(ctx context.Context, e fsnotify.Event) error 33 | watcher *fsnotify.Watcher 34 | close chan struct{} 35 | state state 36 | } 37 | 38 | type state int8 39 | 40 | const ( 41 | _ state = iota 42 | stateClosed 43 | stateRunning 44 | ) 45 | 46 | // New creates a new file watcher that executes onChange for any 47 | // remove/create/change/chmod filesystem event. 48 | // onChange will receive the ctx that was passed to Run. 49 | // baseDir is used as the base path for relative ignore expressions. 50 | func New( 51 | baseDir string, 52 | onChange func(ctx context.Context, e fsnotify.Event) error, 53 | ) (*Watcher, error) { 54 | watcher, err := fsnotify.NewWatcher() 55 | if err != nil { 56 | return nil, err 57 | } 58 | w := &Watcher{ 59 | fileRegistry: filereg.New(), 60 | baseDir: baseDir, 61 | watchedDirs: make(map[string]struct{}), 62 | exclude: make(map[string]glob.Glob), 63 | onChange: onChange, 64 | watcher: watcher, 65 | close: make(chan struct{}), 66 | } 67 | w.runnerStart.Add(1) 68 | return w, nil 69 | } 70 | 71 | var ( 72 | ErrClosed = errors.New("closed") 73 | ErrRunning = errors.New("watcher is already running") 74 | ) 75 | 76 | // WaitRunning blocks and returns once the watcher is running. 77 | // Returns immediately if the watcher is closed or running. 78 | func (w *Watcher) WaitRunning() { 79 | w.lock.Lock() 80 | state := w.state 81 | w.lock.Unlock() 82 | 83 | if state == stateClosed { 84 | return 85 | } 86 | 87 | w.runnerStart.Wait() 88 | } 89 | 90 | // RangeWatchedDirs calls fn for every currently watched directory. 91 | // Noop if the watcher is closed. 92 | func (w *Watcher) RangeWatchedDirs(fn func(path string) (continueIter bool)) { 93 | w.lock.Lock() 94 | defer w.lock.Unlock() 95 | if w.state == stateClosed { 96 | return 97 | } 98 | for p := range w.watchedDirs { 99 | if !fn(p) { 100 | return 101 | } 102 | } 103 | } 104 | 105 | // Close stops watching everything and closes the watcher. 106 | // Noop if the watcher is closed. 107 | func (w *Watcher) Close() error { 108 | w.lock.Lock() 109 | defer w.lock.Unlock() 110 | if w.state != stateRunning || w.state == stateClosed { 111 | return nil 112 | } 113 | close(w.close) 114 | return w.watcher.Close() 115 | } 116 | 117 | // Run runs the watcher. Can only be called once. 118 | // Returns ErrClosed if closed, or ErrRunning if already running. 119 | func (w *Watcher) Run(ctx context.Context) (err error) { 120 | w.lock.Lock() 121 | switch w.state { 122 | case stateClosed: 123 | w.lock.Unlock() 124 | return ErrClosed 125 | case stateRunning: 126 | w.lock.Unlock() 127 | return ErrRunning 128 | } 129 | 130 | func() { // Register all files from the base dir 131 | defer w.lock.Unlock() 132 | err = fswalk.Files(w.baseDir, func(name string) error { 133 | if err := w.isExluded(name); err != nil { 134 | if err == errExcluded { 135 | return nil // Object is excluded from watcher, don't notify 136 | } 137 | return fmt.Errorf("isExluded: %w", err) 138 | } 139 | _, err = w.fileRegistry.Add(name) 140 | return err 141 | }) 142 | 143 | // Signal runner readiness 144 | w.state = stateRunning 145 | w.runnerStart.Done() 146 | }() 147 | if err != nil { 148 | return fmt.Errorf("registering files from base dir: %w", err) 149 | } 150 | 151 | defer func() { _ = w.Close() }() 152 | for { 153 | select { 154 | case <-w.close: 155 | w.lock.Lock() 156 | w.state = stateClosed 157 | w.lock.Unlock() 158 | return ErrClosed // Close called 159 | case <-ctx.Done(): 160 | w.lock.Lock() 161 | close(w.close) 162 | w.state = stateClosed 163 | w.lock.Unlock() 164 | return ctx.Err() // Watching canceled 165 | case e := <-w.watcher.Events: 166 | if e.Name == "" || e.Op == 0 { 167 | continue 168 | } 169 | if err := w.handleEvent(ctx, e); err != nil { 170 | return err 171 | } 172 | case err := <-w.watcher.Errors: 173 | if err != nil { 174 | return fmt.Errorf("watching: %w", err) 175 | } 176 | } 177 | } 178 | } 179 | 180 | func (w *Watcher) handleEvent(ctx context.Context, e fsnotify.Event) error { 181 | w.lock.Lock() 182 | defer w.lock.Unlock() 183 | 184 | if err := w.isExluded(e.Name); err != nil { 185 | if err == errExcluded { 186 | return nil // Object is excluded from watcher, don't notify 187 | } 188 | return fmt.Errorf("isExluded: %w", err) 189 | } 190 | if w.isDirEvent(e) { 191 | switch e.Op { 192 | case fsnotify.Create: 193 | // New sub-directory was created, start watching it. 194 | if err := w.add(e.Name); err != nil { 195 | return fmt.Errorf("adding created directory: %w", err) 196 | } 197 | case fsnotify.Remove, fsnotify.Rename: 198 | // Sub-directory was removed or renamed, stop watching it. 199 | // A new create notification will readd it. 200 | if err := w.remove(e.Name); err != nil { 201 | return fmt.Errorf("removing directory: %w", err) 202 | } 203 | } 204 | } else if e.Op == fsnotify.Write || e.Op == fsnotify.Create { 205 | // A file was created. 206 | updated, err := w.fileRegistry.Add(e.Name) 207 | if err != nil { 208 | // Ignore not exist errors since those are usually triggered 209 | // by tools creating and deleting temporary files so quickly that 210 | // the watcher sees a file change but isn't fast enough to read it. 211 | if errors.Is(err, fs.ErrNotExist) { 212 | log.Errorf("adding created file (%q) to registry: %v", 213 | e.Name, err) 214 | return nil 215 | } 216 | return fmt.Errorf("adding created file (%q) to registry: %w", 217 | e.Name, err) 218 | } 219 | if !updated { // File checksum hasn't changed, ignore event. 220 | return nil 221 | } 222 | } else if e.Op == fsnotify.Rename || e.Op == fsnotify.Remove { 223 | // A file was (re)moved. 224 | w.fileRegistry.Remove(e.Name) 225 | } 226 | return w.onChange(ctx, e) 227 | } 228 | 229 | // Ignore adds an ignore glob filter and removes all currently 230 | // watched directories that match the expression. 231 | // Returns ErrClosed if the watcher is already closed or not running. 232 | func (w *Watcher) Ignore(globExpression string) error { 233 | g, err := glob.Compile(globExpression) 234 | if err != nil { 235 | return err 236 | } 237 | 238 | w.lock.Lock() 239 | defer w.lock.Unlock() 240 | 241 | if w.state == stateClosed { 242 | return ErrClosed 243 | } 244 | 245 | // Reset the file registry because we don't know what files will be excluded. 246 | w.fileRegistry.Reset() 247 | 248 | w.exclude[globExpression] = g 249 | for dir := range w.watchedDirs { 250 | if dir == w.baseDir { 251 | continue 252 | } 253 | err := w.isExluded(dir) 254 | if err == errExcluded { 255 | if err := w.remove(dir); err != nil { 256 | return fmt.Errorf("removing %q: %w", dir, err) 257 | } 258 | } else if err != nil { 259 | return fmt.Errorf("checking exclusion for %q: %w", dir, err) 260 | } 261 | } 262 | return nil 263 | } 264 | 265 | // Unignore removes an ignore glob filter. 266 | // Noop if filter doesn't exist or the watcher is closed or not running. 267 | func (w *Watcher) Unignore(globExpression string) { 268 | w.lock.Lock() 269 | defer w.lock.Unlock() 270 | if w.state == stateClosed { 271 | return 272 | } 273 | delete(w.exclude, globExpression) 274 | } 275 | 276 | // Add starts watching the directory and all of its subdirectories recursively. 277 | // Returns ErrClosed if the watcher is already closed or not running. 278 | func (w *Watcher) Add(dir string) error { 279 | w.lock.Lock() 280 | defer w.lock.Unlock() 281 | if w.state == stateClosed { 282 | return ErrClosed 283 | } 284 | return w.add(dir) 285 | } 286 | 287 | func (w *Watcher) add(dir string) error { 288 | log.Debugf("watching directory: %q", dir) 289 | err := forEachDir(dir, func(dir string) error { 290 | if err := w.isExluded(dir); err != nil { 291 | if err == errExcluded { 292 | return nil // Directory is exluded from watching. 293 | } 294 | } 295 | if _, ok := w.watchedDirs[dir]; ok { 296 | return errStopTraversal // Directory already watched. 297 | } 298 | w.watchedDirs[dir] = struct{}{} 299 | return w.watcher.Add(dir) 300 | }) 301 | if err == errStopTraversal { 302 | return nil 303 | } 304 | return err 305 | } 306 | 307 | var errStopTraversal = errors.New("stop recursive traversal") 308 | 309 | // Remove stops watching the directory and all of its subdirectories recursively. 310 | // Returns ErrClosed if the watcher is already closed or not running. 311 | func (w *Watcher) Remove(dir string) error { 312 | w.lock.Lock() 313 | defer w.lock.Unlock() 314 | if w.state == stateClosed { 315 | return ErrClosed 316 | } 317 | return w.remove(dir) 318 | } 319 | 320 | func (w *Watcher) remove(dir string) error { 321 | if _, ok := w.watchedDirs[dir]; !ok { 322 | return nil 323 | } 324 | log.Debugf("unwatch directory: %q", dir) 325 | delete(w.watchedDirs, dir) 326 | if err := w.removeWatcher(dir); err != nil { 327 | return err 328 | } 329 | 330 | // Stop all sub-directory watchers 331 | for p := range w.watchedDirs { 332 | if strings.HasPrefix(p, dir) { 333 | delete(w.watchedDirs, p) 334 | if err := w.removeWatcher(dir); err != nil { 335 | return err 336 | } 337 | } 338 | } 339 | 340 | // Remove all files from the registry 341 | w.fileRegistry.RemoveWithPrefix(dir) 342 | 343 | return nil 344 | } 345 | 346 | // removeWatcher ignores ErrNonExistentWatch when removing a watcher. 347 | func (w *Watcher) removeWatcher(dir string) error { 348 | if err := w.watcher.Remove(dir); err != nil { 349 | if !errors.Is(err, fsnotify.ErrNonExistentWatch) { 350 | return err 351 | } 352 | } 353 | return nil 354 | } 355 | 356 | // isExluded returns errExcluded if path is excluded, otherwise returns nil. 357 | func (w *Watcher) isExluded(path string) error { 358 | relPath, err := filepath.Rel(w.baseDir, path) 359 | if err != nil { 360 | return fmt.Errorf("determining relative path (base: %q; path: %q): %w", 361 | w.baseDir, path, err) 362 | } 363 | if relPath == "." { 364 | // The working directory shall never be excluded based on globs. 365 | return nil 366 | } 367 | for _, x := range w.exclude { 368 | if x.Match(relPath) { 369 | return errExcluded 370 | } 371 | } 372 | return nil 373 | } 374 | 375 | var errExcluded = errors.New("path excluded") 376 | 377 | func (w *Watcher) isDirEvent(e fsnotify.Event) bool { 378 | switch e.Op { 379 | case fsnotify.Create, fsnotify.Write, fsnotify.Chmod: 380 | fileInfo, err := os.Stat(e.Name) 381 | if err != nil { 382 | return false 383 | } 384 | return fileInfo.IsDir() 385 | } 386 | _, ok := w.watchedDirs[e.Name] 387 | return ok 388 | } 389 | 390 | // forEachDir executes fn for every subdirectory of pathDir, 391 | // including pathDir itself, recursively. 392 | func forEachDir(pathDir string, fn func(dir string) error) error { 393 | // Use filepath.Walk to traverse directories 394 | err := filepath.Walk(pathDir, func(path string, info os.FileInfo, err error) error { 395 | if err != nil { 396 | return err // Stop walking the directory tree. 397 | } 398 | if !info.IsDir() { 399 | return nil // Continue walking. 400 | } 401 | if err = fn(path); err != nil { 402 | return err 403 | } 404 | return nil 405 | }) 406 | return err 407 | } 408 | -------------------------------------------------------------------------------- /internal/watcher/watcher_test.go: -------------------------------------------------------------------------------- 1 | package watcher_test 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "sync" 8 | "testing" 9 | 10 | "github.com/romshark/templier/internal/watcher" 11 | 12 | "github.com/fsnotify/fsnotify" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestWatcher(t *testing.T) { 17 | t.Parallel() 18 | 19 | base, notifications := t.TempDir(), make(chan fsnotify.Event) 20 | w := runNewWatcher(t, base, notifications) 21 | 22 | // Create a sub-directory that exists even before Run 23 | MustMkdir(t, base, "existing-subdir") 24 | 25 | require.NoError(t, w.Add(base)) 26 | 27 | ExpectWatched(t, w, []string{ 28 | base, 29 | filepath.Join(base, "existing-subdir"), 30 | }) 31 | 32 | events := make([]fsnotify.Event, 10) 33 | 34 | // After every operation, wait for fsnotify to trigger, 35 | // otherwise events might get lost. 36 | MustCreateFile(t, base, "newfile") 37 | events[0] = <-notifications 38 | 39 | MustMkdir(t, base, "newdir") 40 | events[1] = <-notifications 41 | ExpectWatched(t, w, []string{ 42 | base, 43 | filepath.Join(base, "existing-subdir"), 44 | filepath.Join(base, "newdir"), 45 | }) 46 | 47 | MustMkdir(t, base, "newdir", "subdir") 48 | events[2] = <-notifications 49 | ExpectWatched(t, w, []string{ 50 | base, 51 | filepath.Join(base, "existing-subdir"), 52 | filepath.Join(base, "newdir"), 53 | filepath.Join(base, "newdir", "subdir"), 54 | }) 55 | 56 | MustCreateFile(t, base, "newdir", "subdir", "subfile") 57 | events[3] = <-notifications 58 | 59 | MustCreateFile(t, base, "newdir", "subdir", "subfile2") 60 | events[4] = <-notifications 61 | 62 | MustCreateFile(t, base, "existing-subdir", "subfile3") 63 | events[5] = <-notifications 64 | 65 | MustRemove(t, base, "existing-subdir", "subfile3") 66 | events[6] = <-notifications 67 | 68 | MustRemove(t, base, "existing-subdir") 69 | events[7] = <-notifications 70 | 71 | // Renaming will generate two events, first the renaming event and later 72 | // the event of creation of a new directory. 73 | MustRename(t, filepath.Join(base, "newdir"), filepath.Join(base, "newname")) 74 | events[8] = <-notifications 75 | events[9] = <-notifications 76 | ExpectWatched(t, w, []string{ 77 | base, 78 | filepath.Join(base, "newname"), 79 | filepath.Join(base, "newname/subdir"), 80 | }) 81 | 82 | // Event 0 83 | eventsMustContain(t, events, fsnotify.Event{ 84 | Op: fsnotify.Create, 85 | Name: filepath.Join(base, "newfile"), 86 | }) 87 | // Event 1 88 | eventsMustContain(t, events, fsnotify.Event{ 89 | Op: fsnotify.Create, 90 | Name: filepath.Join(base, "newdir"), 91 | }) 92 | // Event 2 93 | eventsMustContain(t, events, fsnotify.Event{ 94 | Op: fsnotify.Create, 95 | Name: filepath.Join(base, "newdir", "subdir"), 96 | }) 97 | // Event 3 98 | eventsMustContain(t, events, fsnotify.Event{ 99 | Op: fsnotify.Create, 100 | Name: filepath.Join(base, "newdir", "subdir", "subfile"), 101 | }) 102 | // Event 4 103 | eventsMustContain(t, events, fsnotify.Event{ 104 | Op: fsnotify.Create, 105 | Name: filepath.Join(base, "newdir", "subdir", "subfile2"), 106 | }) 107 | // Event 5 108 | eventsMustContain(t, events, fsnotify.Event{ 109 | Op: fsnotify.Create, 110 | Name: filepath.Join(base, "existing-subdir", "subfile3"), 111 | }) 112 | // Event 6 113 | eventsMustContain(t, events, fsnotify.Event{ 114 | Op: fsnotify.Remove, 115 | Name: filepath.Join(base, "existing-subdir", "subfile3"), 116 | }) 117 | // Event 7 118 | eventsMustContain(t, events, fsnotify.Event{ 119 | Op: fsnotify.Remove, 120 | Name: filepath.Join(base, "existing-subdir"), 121 | }) 122 | // Event 8 123 | eventsMustContain(t, events, fsnotify.Event{ 124 | Op: fsnotify.Rename, 125 | Name: filepath.Join(base, "newdir"), 126 | }) 127 | // Event 9 128 | eventsMustContain(t, events, fsnotify.Event{ 129 | Op: fsnotify.Create, 130 | Name: filepath.Join(base, "newname"), 131 | }) 132 | } 133 | 134 | // TestTemplTempFiles tests the templ temp file scenario. 135 | // When `templ fmt` is executed it creates a temporary formatted file 136 | // then replaces the original file with the temp files. 137 | // The watcher must ignore the temporary files in this scenario. 138 | func TestTemplTempFiles(t *testing.T) { 139 | t.Parallel() 140 | 141 | base, notifications := t.TempDir(), make(chan fsnotify.Event) 142 | w := runNewWatcher(t, base, notifications) 143 | 144 | require.NoError(t, w.Ignore("*.templ[0-9]*")) 145 | require.NoError(t, w.Add(base)) 146 | ExpectWatched(t, w, []string{base}) 147 | 148 | events := make([]fsnotify.Event, 3) 149 | 150 | // After every operation, wait for fsnotify to trigger, 151 | // otherwise events might get lost. 152 | 153 | MustCreateFile(t, base, "test.templ") 154 | events[0] = <-notifications 155 | 156 | // This file should be ignored. 157 | MustCreateFile(t, base, "test.templ123456") 158 | 159 | MustRemove(t, base, "test.templ") 160 | events[1] = <-notifications 161 | 162 | MustRename(t, 163 | filepath.Join(base, "test.templ123456"), 164 | filepath.Join(base, "test.templ")) 165 | events[2] = <-notifications 166 | 167 | // Event 0 168 | eventsMustContain(t, events, fsnotify.Event{ 169 | Op: fsnotify.Create, 170 | Name: filepath.Join(base, "test.templ"), 171 | }) 172 | // Event 1 173 | eventsMustContain(t, events, fsnotify.Event{ 174 | Op: fsnotify.Remove, 175 | Name: filepath.Join(base, "test.templ"), 176 | }) 177 | // Event 2 178 | eventsMustContain(t, events, fsnotify.Event{ 179 | Op: fsnotify.Create, 180 | Name: filepath.Join(base, "test.templ"), 181 | }) 182 | } 183 | 184 | func eventsMustContain(t *testing.T, set []fsnotify.Event, contains fsnotify.Event) { 185 | for _, e := range set { 186 | if e.Op == contains.Op && e.Name == contains.Name { 187 | return 188 | } 189 | } 190 | t.Errorf("event set %#v doesn't contain event %#v", set, contains) 191 | } 192 | 193 | func TestWatcherRunCancelContext(t *testing.T) { 194 | t.Parallel() 195 | 196 | base := t.TempDir() 197 | w, err := watcher.New(base, func(ctx context.Context, e fsnotify.Event) error { 198 | return nil 199 | }) 200 | require.NoError(t, err) 201 | 202 | chErr := make(chan error, 1) 203 | ctx, cancel := context.WithCancel(context.Background()) 204 | go func() { chErr <- w.Run(ctx) }() 205 | require.NoError(t, w.Add(base)) 206 | w.WaitRunning() 207 | 208 | ExpectWatched(t, w, []string{base}) 209 | 210 | cancel() 211 | require.ErrorIs(t, <-chErr, context.Canceled) 212 | 213 | require.ErrorIs(t, w.Add("new"), watcher.ErrClosed) 214 | require.ErrorIs(t, w.Remove("new"), watcher.ErrClosed) 215 | require.ErrorIs(t, w.Run(context.Background()), watcher.ErrClosed) 216 | require.ErrorIs(t, w.Ignore(".ignored"), watcher.ErrClosed) 217 | ExpectWatched(t, w, []string{}) 218 | } 219 | 220 | func TestWatcherErrRunning(t *testing.T) { 221 | t.Parallel() 222 | 223 | base := t.TempDir() 224 | w := runNewWatcher(t, base, nil) 225 | require.NoError(t, w.Add(base)) // Wait for the runner to start 226 | require.ErrorIs(t, w.Run(context.Background()), watcher.ErrRunning) 227 | } 228 | 229 | func TestWatcherAdd_AlreadyWatched(t *testing.T) { 230 | t.Parallel() 231 | 232 | base := t.TempDir() 233 | w := runNewWatcher(t, base, nil) 234 | 235 | ExpectWatched(t, w, []string{}) 236 | require.NoError(t, w.Add(base)) 237 | ExpectWatched(t, w, []string{base}) 238 | require.NoError(t, w.Add(base)) // Add again 239 | ExpectWatched(t, w, []string{base}) 240 | } 241 | 242 | func TestWatcherRemove(t *testing.T) { 243 | t.Parallel() 244 | 245 | base := t.TempDir() 246 | w := runNewWatcher(t, base, nil) 247 | 248 | MustMkdir(t, base, "sub") 249 | MustMkdir(t, base, "sub", "subsub") 250 | MustMkdir(t, base, "sub", "subsub2") 251 | MustMkdir(t, base, "sub", "subsub2", "subsubsub") 252 | MustMkdir(t, base, "sub2") 253 | 254 | ExpectWatched(t, w, []string{}) 255 | 256 | require.NoError(t, w.Add(base)) 257 | ExpectWatched(t, w, []string{ 258 | base, 259 | filepath.Join(base, "sub"), 260 | filepath.Join(base, "sub", "subsub"), 261 | filepath.Join(base, "sub", "subsub2"), 262 | filepath.Join(base, "sub", "subsub2", "subsubsub"), 263 | filepath.Join(base, "sub2"), 264 | }) 265 | 266 | require.NoError(t, w.Remove(filepath.Join(base, "sub", "subsub2", "subsubsub"))) 267 | ExpectWatched(t, w, []string{ 268 | base, 269 | filepath.Join(base, "sub"), 270 | filepath.Join(base, "sub", "subsub"), 271 | filepath.Join(base, "sub", "subsub2"), 272 | filepath.Join(base, "sub2"), 273 | }) 274 | 275 | require.NoError(t, w.Remove(base)) 276 | ExpectWatched(t, w, []string{}) 277 | } 278 | 279 | func TestWatcherIgnore(t *testing.T) { 280 | t.Parallel() 281 | 282 | base := t.TempDir() 283 | MustMkdir(t, base, ".hidden") 284 | notifications := make(chan fsnotify.Event, 2) 285 | w := runNewWatcher(t, base, notifications) 286 | 287 | require.NoError(t, w.Add(base)) 288 | require.NoError(t, w.Add(filepath.Join(base, ".hidden"))) 289 | 290 | ExpectWatched(t, w, []string{base, filepath.Join(base, ".hidden")}) 291 | 292 | require.NoError(t, w.Ignore(".*")) 293 | // Expect .hidden watchers to be stopped 294 | ExpectWatched(t, w, []string{base}) 295 | 296 | // Expect the following events to be ignored. 297 | MustCreateFile(t, base, ".ignore") 298 | MustMkdir(t, base, ".ignorenewdir") 299 | MustCreateFile(t, base, ".hidden", "ignored") 300 | 301 | // Expect only those events to end up in notifications. 302 | MustCreateFile(t, base, "notignored") 303 | MustMkdir(t, base, "notignoreddir") 304 | 305 | require.Equal(t, fsnotify.Event{ 306 | Op: fsnotify.Create, 307 | Name: filepath.Join(base, "notignored"), 308 | }, <-notifications) 309 | 310 | require.Equal(t, fsnotify.Event{ 311 | Op: fsnotify.Create, 312 | Name: filepath.Join(base, "notignoreddir"), 313 | }, <-notifications) 314 | 315 | ExpectWatched(t, w, []string{ 316 | base, 317 | filepath.Join(base, "notignoreddir"), 318 | }) 319 | 320 | require.Len(t, notifications, 0) 321 | } 322 | 323 | func TestWatcherUnignore(t *testing.T) { 324 | t.Parallel() 325 | 326 | base, notifications := t.TempDir(), make(chan fsnotify.Event) 327 | w := runNewWatcher(t, base, notifications) 328 | 329 | require.NoError(t, w.Add(base)) 330 | ExpectWatched(t, w, []string{base}) 331 | 332 | { 333 | p := filepath.Join(base, ".*") 334 | require.NoError(t, w.Ignore(p)) 335 | w.Unignore(p) 336 | } 337 | 338 | MustMkdir(t, base, ".hidden") 339 | require.Equal(t, fsnotify.Event{ 340 | Op: fsnotify.Create, 341 | Name: filepath.Join(base, ".hidden"), 342 | }, <-notifications) 343 | ExpectWatched(t, w, []string{base, filepath.Join(base, ".hidden")}) 344 | } 345 | 346 | func ExpectWatched(t *testing.T, w *watcher.Watcher, expect []string) { 347 | t.Helper() 348 | actual := []string{} 349 | w.RangeWatchedDirs(func(path string) (continueIter bool) { 350 | actual = append(actual, path) 351 | return true 352 | }) 353 | require.Len(t, actual, len(expect), "actual: %v", actual) 354 | for _, exp := range expect { 355 | require.Contains(t, actual, exp) 356 | } 357 | } 358 | 359 | func MustMkdir(t *testing.T, pathParts ...string) { 360 | t.Helper() 361 | err := os.Mkdir(filepath.Join(pathParts...), 0o777) 362 | require.NoError(t, err) 363 | } 364 | 365 | func MustCreateFile(t *testing.T, pathParts ...string) *os.File { 366 | t.Helper() 367 | f, err := os.Create(filepath.Join(pathParts...)) 368 | require.NoError(t, err) 369 | return f 370 | } 371 | 372 | func MustRemove(t *testing.T, pathParts ...string) { 373 | t.Helper() 374 | err := os.Remove(filepath.Join(pathParts...)) 375 | require.NoError(t, err) 376 | } 377 | 378 | func MustRename(t *testing.T, from, to string) { 379 | t.Helper() 380 | err := os.Rename(from, to) 381 | require.NoError(t, err) 382 | } 383 | 384 | // TestConcurrency requires go test -race 385 | func TestConcurrency(t *testing.T) { 386 | t.Parallel() 387 | 388 | base := t.TempDir() 389 | w := runNewWatcher(t, base, nil) 390 | 391 | var wg sync.WaitGroup 392 | wg.Add(4) 393 | go func() { defer wg.Done(); panicOnErr(w.Ignore(".ignored")) }() 394 | go func() { defer wg.Done(); w.Unignore(".ignored") }() 395 | go func() { defer wg.Done(); panicOnErr(w.Add(base)) }() 396 | go func() { defer wg.Done(); panicOnErr(w.Remove(base)) }() 397 | wg.Wait() 398 | } 399 | 400 | func runNewWatcher( 401 | t *testing.T, baseDir string, notify chan<- fsnotify.Event, 402 | ) *watcher.Watcher { 403 | t.Helper() 404 | w, err := watcher.New(baseDir, func(ctx context.Context, e fsnotify.Event) error { 405 | if notify != nil { 406 | notify <- e 407 | } 408 | return nil 409 | }) 410 | require.NoError(t, err) 411 | 412 | var wg sync.WaitGroup 413 | wg.Add(1) 414 | t.Cleanup(func() { 415 | require.NoError(t, w.Close()) 416 | wg.Wait() // Wait until the runner stops 417 | }) 418 | go func() { 419 | defer wg.Done() 420 | err := w.Run(context.Background()) 421 | if err == nil || err == watcher.ErrClosed { 422 | return 423 | } 424 | panic(err) 425 | }() 426 | w.WaitRunning() 427 | return w 428 | } 429 | 430 | func panicOnErr(err error) { 431 | if err != nil { 432 | panic(err) 433 | } 434 | } 435 | -------------------------------------------------------------------------------- /logo_color.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /logo_white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | _ "embed" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "os/exec" 15 | "os/signal" 16 | "path/filepath" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "sync/atomic" 21 | "time" 22 | 23 | "github.com/romshark/templier/internal/action" 24 | "github.com/romshark/templier/internal/broadcaster" 25 | "github.com/romshark/templier/internal/cmdrun" 26 | "github.com/romshark/templier/internal/config" 27 | "github.com/romshark/templier/internal/ctxrun" 28 | "github.com/romshark/templier/internal/debounce" 29 | "github.com/romshark/templier/internal/fswalk" 30 | "github.com/romshark/templier/internal/log" 31 | "github.com/romshark/templier/internal/server" 32 | "github.com/romshark/templier/internal/statetrack" 33 | "github.com/romshark/templier/internal/templgofilereg" 34 | "github.com/romshark/templier/internal/watcher" 35 | "golang.org/x/sync/errgroup" 36 | 37 | "github.com/fsnotify/fsnotify" 38 | "github.com/gobwas/glob" 39 | ) 40 | 41 | const ServerHealthPreflightWaitInterval = 100 * time.Millisecond 42 | 43 | var ( 44 | // rerunActive indicates whether the currently running app server 45 | // is being restarted. The appLauncher 46 | rerunActive atomic.Bool 47 | 48 | chRerunServer = make(chan struct{}, 1) 49 | chRunNewServer = make(chan string, 1) 50 | ) 51 | 52 | type customWatcher struct { 53 | name string 54 | cmd config.CmdStr 55 | include []glob.Glob 56 | exclude []glob.Glob 57 | debounced func(func()) 58 | failOnErr bool 59 | requires action.Type 60 | } 61 | 62 | func (c customWatcher) isFilePathIncluded(s string) bool { 63 | for _, glob := range c.include { 64 | if glob.Match(s) { 65 | for _, glob := range c.exclude { 66 | if glob.Match(s) { 67 | return false 68 | } 69 | } 70 | return true 71 | } 72 | } 73 | return false 74 | } 75 | 76 | func main() { 77 | conf := config.MustParse() 78 | log.SetLogLevel(log.LogLevel(conf.Log.Level)) 79 | 80 | if err := checkTemplVersion(context.Background()); err != nil { 81 | log.Fatalf("checking templ version: %v", err) 82 | } 83 | 84 | // Make sure required cmds are available. 85 | if _, err := exec.LookPath("templ"); err != nil { 86 | log.FatalCmdNotAvailable( 87 | "templ", "https://templ.guide/quick-start/installation", 88 | ) 89 | } 90 | if conf.Lint { 91 | if _, err := exec.LookPath("golangci-lint"); err != nil { 92 | log.FatalCmdNotAvailable( 93 | "golangci-lint", 94 | "https://github.com/golangci/golangci-lint"+ 95 | "?tab=readme-ov-file#install-golangci-lint", 96 | ) 97 | } 98 | } 99 | for _, w := range conf.CustomWatchers { 100 | if w.Cmd == "" { 101 | continue 102 | } 103 | cmd := w.Cmd.Cmd() 104 | if _, err := exec.LookPath(cmd); err != nil { 105 | log.FatalCustomWatcherCmdNotAvailable(cmd, string(w.Name)) 106 | } 107 | } 108 | 109 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 110 | defer cancel() 111 | 112 | // Create a temporary directory 113 | tempDirPath, err := os.MkdirTemp("", "templier-*") 114 | if err != nil { 115 | log.Fatalf("creating temporary directory: %v\n", err) 116 | } 117 | log.Debugf("set server binaries output path: %q", tempDirPath) 118 | defer func() { 119 | log.Debugf("removing server binaries output directory: %s", tempDirPath) 120 | if err := os.RemoveAll(tempDirPath); err != nil { 121 | log.Errorf("deleting temporary directory: %v\n", err) 122 | } 123 | }() 124 | 125 | // At this point, use of fatal logging is no longer allowed. 126 | // Use panic instead to ensure deferred functions are executed. 127 | 128 | reload := broadcaster.NewSignalBroadcaster() 129 | st := statetrack.NewTracker(len(conf.CustomWatchers)) 130 | 131 | errgrp, ctx := errgroup.WithContext(ctx) 132 | var wg sync.WaitGroup 133 | 134 | wg.Add(1) 135 | errgrp.Go(func() error { 136 | defer wg.Done() 137 | // Run templ in watch mode to create debug components. 138 | // Once ctx is canceled templ will write production output and exit. 139 | err := cmdrun.RunTemplWatch(ctx, conf.App.DirSrcRootAbsolute(), st) 140 | if err != nil && !errors.Is(err, context.Canceled) { 141 | err = fmt.Errorf("running 'templ generate --watch': %w", err) 142 | log.Error(err.Error()) 143 | } 144 | log.Debugf("'templ generate --watch' stopped") 145 | return err 146 | }) 147 | 148 | // Initialize custom customWatchers. 149 | customWatchers := make([]customWatcher, len(conf.CustomWatchers)) 150 | for i, w := range conf.CustomWatchers { 151 | debouncer, debounced := debounce.NewSync(w.Debounce) 152 | go debouncer(ctx) 153 | 154 | // The following globs have already been validated during config parsing. 155 | // It's safe to assume compilation succeeds. 156 | include := make([]glob.Glob, len(w.Include)) 157 | for i, pattern := range w.Include { 158 | include[i] = glob.MustCompile(pattern) 159 | } 160 | exclude := make([]glob.Glob, len(w.Exclude)) 161 | for i, pattern := range w.Exclude { 162 | exclude[i] = glob.MustCompile(pattern) 163 | } 164 | 165 | customWatchers[i] = customWatcher{ 166 | name: string(w.Name), 167 | debounced: debounced, 168 | cmd: w.Cmd, 169 | failOnErr: w.FailOnError, 170 | include: include, 171 | exclude: exclude, 172 | requires: action.Type(w.Requires), 173 | } 174 | } 175 | 176 | wg.Add(1) 177 | errgrp.Go(func() error { 178 | defer wg.Done() 179 | err := runTemplierServer(ctx, st, reload, conf) 180 | if err != nil { 181 | err = fmt.Errorf("running templier server: %w", err) 182 | log.Error(err.Error()) 183 | } 184 | log.Debugf("templier server stopped") 185 | return err 186 | }) 187 | 188 | wg.Add(1) 189 | errgrp.Go(func() error { 190 | defer wg.Done() 191 | runAppLauncher(ctx, st, reload, conf) 192 | log.Debugf("app launcher stopped") 193 | return nil 194 | }) 195 | 196 | debouncer, debounced := debounce.NewSync(conf.Debounce) 197 | go debouncer(ctx) 198 | 199 | // Initial build, run all custom watcher cmd's and if they succeed then lint & build 200 | for i, watcher := range conf.CustomWatchers { 201 | o, err := cmdrun.Sh(ctx, conf.App.DirWork, string(watcher.Cmd)) 202 | output := string(o) 203 | if errors.Is(err, cmdrun.ErrExitCode1) { 204 | if !watcher.FailOnError { 205 | log.Errorf( 206 | "custom watcher %q exited with code 1: %s", 207 | watcher.Cmd, output, 208 | ) 209 | continue 210 | } 211 | st.Set(statetrack.IndexOffsetCustomWatcher+i, string(o)) 212 | continue 213 | } else if err != nil { 214 | log.Errorf("running custom watcher cmd %q: %v", watcher.Cmd, err) 215 | continue 216 | } 217 | st.Set(statetrack.IndexOffsetCustomWatcher+i, "") 218 | } 219 | 220 | // Finalize initial build 221 | if binaryFile := lintAndBuildServer(ctx, st, conf, tempDirPath); binaryFile != "" { 222 | // Launch only when there's no errors on initial build. 223 | chRunNewServer <- binaryFile 224 | } 225 | 226 | // Collect all _templ.go files and add them to the registry 227 | templGoFileReg := templgofilereg.New() 228 | if currentWorkingDir, err := os.Getwd(); err != nil { 229 | log.Errorf("getting current working directory: %v", err) 230 | } else { 231 | if err := fswalk.Files(currentWorkingDir, func(name string) error { 232 | if !strings.HasSuffix(name, "_templ.go") { 233 | return nil 234 | } 235 | if _, err := templGoFileReg.Compare(name); err != nil { 236 | log.Errorf("registering generated templ Go file: %q: %v", name, err) 237 | } 238 | log.Debugf("registered existing generated templ Go file: %q", name) 239 | return nil 240 | }); err != nil { 241 | log.Errorf("collecting existing _templ.go files: %v", err) 242 | } 243 | } 244 | 245 | onChangeHandler := FileChangeHandler{ 246 | binaryOutBasePath: tempDirPath, 247 | customWatchers: customWatchers, 248 | stateTracker: st, 249 | reload: reload, 250 | debounced: debounced, 251 | conf: conf, 252 | templGoFileRegistry: templGoFileReg, 253 | buildRunner: ctxrun.New(), 254 | } 255 | 256 | onChangeHandler.watchBasePath, err = filepath.Abs(conf.App.DirWork) 257 | if err != nil { 258 | panic(fmt.Errorf("determining absolute base file path: %v", err)) 259 | } 260 | log.Debugf("set absolute base file path: %q", onChangeHandler.watchBasePath) 261 | 262 | watcher, err := watcher.New(conf.App.DirSrcRootAbsolute(), onChangeHandler.Handle) 263 | if err != nil { 264 | panic(fmt.Errorf("initializing file watcher: %w", err)) 265 | } 266 | 267 | for _, expr := range conf.App.Exclude { 268 | if err := watcher.Ignore(expr); err != nil { 269 | panic(fmt.Errorf("adding ignore filter to watcher (%q): %w", expr, err)) 270 | } 271 | } 272 | 273 | // Ignore templ temp files generated by `templ fmt`. 274 | if err := watcher.Ignore("*.templ[0-9]*"); err != nil { 275 | panic(fmt.Errorf( 276 | `adding ignore templ temp files filter to watcher ("*.templ*"): %w`, 277 | err, 278 | )) 279 | } 280 | 281 | if err := watcher.Add(conf.App.DirSrcRootAbsolute()); err != nil { 282 | panic(fmt.Errorf("setting up file watcher for app.dir-src-root(%q): %w", 283 | conf.App.DirSrcRootAbsolute(), err)) 284 | } 285 | 286 | wg.Add(1) 287 | errgrp.Go(func() error { 288 | defer wg.Done() 289 | err := watcher.Run(ctx) 290 | if err != nil && !errors.Is(err, context.Canceled) { 291 | err = fmt.Errorf("running file watcher: %w", err) 292 | log.Error(err.Error()) 293 | } 294 | log.Debugf("file watcher stopped") 295 | return err 296 | }) 297 | 298 | { 299 | templierBaseURL := url.URL{ 300 | Scheme: "http", 301 | Host: conf.TemplierHost, 302 | } 303 | if conf.TLS != nil { 304 | templierBaseURL.Scheme = "https" 305 | } 306 | 307 | log.TemplierStarted(templierBaseURL.String()) 308 | } 309 | 310 | if err := errgrp.Wait(); err != nil { 311 | log.Debugf("sub-process failure: %v", err) 312 | } 313 | cancel() // Ask all sub-processes to exit gracefully. 314 | log.Debugf("waiting for remaining sub-processes to shut down") 315 | wg.Wait() // Wait for all sub-processes to exit. 316 | } 317 | 318 | func runTemplierServer( 319 | ctx context.Context, 320 | st *statetrack.Tracker, 321 | reload *broadcaster.SignalBroadcaster, 322 | conf *config.Config, 323 | ) error { 324 | httpSrv := http.Server{ 325 | Addr: conf.TemplierHost, 326 | Handler: server.New( 327 | &http.Client{ 328 | Timeout: conf.ProxyTimeout, 329 | }, 330 | st, 331 | reload, 332 | conf, 333 | ), 334 | } 335 | 336 | var errgrp errgroup.Group 337 | errgrp.Go(func() error { 338 | var err error 339 | if conf.TLS != nil { 340 | err = httpSrv.ListenAndServeTLS(conf.TLS.Cert, conf.TLS.Key) 341 | } else { 342 | err = httpSrv.ListenAndServe() 343 | } 344 | if errors.Is(err, http.ErrServerClosed) { 345 | return nil 346 | } 347 | return err 348 | }) 349 | errgrp.Go(func() error { 350 | <-ctx.Done() // Wait for shutdown signal. 351 | return httpSrv.Shutdown(ctx) 352 | }) 353 | 354 | return errgrp.Wait() 355 | } 356 | 357 | func runAppLauncher( 358 | ctx context.Context, 359 | stateTracker *statetrack.Tracker, 360 | reload *broadcaster.SignalBroadcaster, 361 | conf *config.Config, 362 | ) { 363 | var latestSrvCmd *exec.Cmd 364 | var latestBinaryPath string 365 | var waitExit sync.WaitGroup 366 | 367 | stopServer := func() (stopped bool) { 368 | if latestSrvCmd == nil || latestSrvCmd.Process == nil { 369 | return false 370 | } 371 | log.Debugf("stopping app server with pid %d", latestSrvCmd.Process.Pid) 372 | if err := latestSrvCmd.Process.Signal(os.Interrupt); err != nil { 373 | log.Errorf("sending interrupt signal to app server: %v", err) 374 | return false 375 | } 376 | return true 377 | } 378 | 379 | healthCheckClient := &http.Client{Transport: &http.Transport{ 380 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 381 | }} 382 | 383 | rerun := func(ctx context.Context) { 384 | rerunActive.Store(true) 385 | defer rerunActive.Store(false) 386 | 387 | start := time.Now() 388 | stopped := stopServer() 389 | waitExit.Wait() 390 | 391 | if stopped { 392 | log.Durf("stopped server", time.Since(start)) 393 | } 394 | 395 | if stateTracker.ErrIndex() != -1 { 396 | // There's some error, we can't rerun now. 397 | return 398 | } 399 | if ctx.Err() != nil { 400 | return // Canceled. 401 | } 402 | 403 | c := exec.Command(latestBinaryPath) 404 | if conf.App.Flags != nil { 405 | c.Args = append(c.Args, conf.App.Flags...) 406 | } 407 | 408 | // Enable templ's development mode to read from .txt 409 | // for faster reloads without recompilation. 410 | c.Env = append(os.Environ(), "TEMPL_DEV_MODE=true") 411 | 412 | var bufOutputCombined bytes.Buffer 413 | 414 | c.Stdout = io.MultiWriter(os.Stdout, &bufOutputCombined) 415 | c.Stderr = io.MultiWriter(os.Stderr, &bufOutputCombined) 416 | latestSrvCmd = c 417 | 418 | log.TemplierRestartingServer(conf.App.DirCmd) 419 | 420 | log.Debugf("running app server command: %s", latestSrvCmd.String()) 421 | if err := c.Start(); err != nil { 422 | log.Errorf("running %s: %v", conf.App.DirCmd, err) 423 | } 424 | if c.Process != nil { 425 | log.Debugf("app server running (pid: %d)", c.Process.Pid) 426 | } 427 | 428 | var exitCode atomic.Int32 429 | exitCode.Store(-1) 430 | waitExit.Add(1) 431 | go func() { 432 | defer waitExit.Done() 433 | err := c.Wait() 434 | if err == nil { 435 | return 436 | } 437 | if exitError, ok := err.(*exec.ExitError); ok { 438 | // The program has exited with an exit code != 0 439 | exitCode.Store(int32(exitError.ExitCode())) 440 | return 441 | } 442 | // Some other error occurred 443 | log.Errorf("health check: waiting for process: %v", err) 444 | }() 445 | 446 | const maxRetries = 100 447 | for retry := 0; ; retry++ { 448 | if ctx.Err() != nil { 449 | // App launcher stopped or rerun canceled. 450 | log.Debugf("rerun canceled") 451 | return 452 | } 453 | if retry > maxRetries { 454 | log.Errorf("waiting for server: %d retries failed", maxRetries) 455 | return 456 | } 457 | // Wait for the server to be ready 458 | log.Debugf("health check (%d/%d): %s %q", 459 | retry, maxRetries, http.MethodOptions, conf.App.Host.URL.String()) 460 | r, err := http.NewRequest( 461 | http.MethodOptions, conf.App.Host.URL.String(), http.NoBody, 462 | ) 463 | r = r.WithContext(ctx) 464 | if err != nil { 465 | log.Errorf("initializing preflight request: %v", err) 466 | continue 467 | } 468 | resp, err := healthCheckClient.Do(r) 469 | if err == nil { 470 | _ = resp.Body.Close() 471 | log.Debugf("health check: OK, " + 472 | "app server is ready to receive requests") 473 | break // Server is ready to receive requests 474 | } 475 | if errors.Is(err, context.Canceled) { 476 | return 477 | } 478 | log.Debugf("health check: err: %v", err) 479 | if code := exitCode.Load(); code != -1 && code != 0 { 480 | log.Errorf("health check: app server exited with exit code %d", code) 481 | stateTracker.Set(statetrack.IndexExit, bufOutputCombined.String()) 482 | return 483 | } 484 | log.Debugf("health check: wait: %s", ServerHealthPreflightWaitInterval) 485 | time.Sleep(ServerHealthPreflightWaitInterval) 486 | } 487 | 488 | if conf.Log.ClearOn == config.LogClearOnRestart { 489 | log.ClearLogs() 490 | } 491 | 492 | if stopped { 493 | log.Durf("restarted server", time.Since(start)) 494 | } else { 495 | log.Durf("started server", time.Since(start)) 496 | } 497 | 498 | // Notify all clients to reload the page 499 | reload.BroadcastNonblock() 500 | } 501 | 502 | runner := ctxrun.New() 503 | for { 504 | select { 505 | case <-chRerunServer: 506 | runner.Go(ctx, func(ctx context.Context) { 507 | rerun(ctx) 508 | }) 509 | case newBinaryPath := <-chRunNewServer: 510 | runner.Go(ctx, func(ctx context.Context) { 511 | if latestBinaryPath != "" { 512 | log.Debugf("remove executable: %s", latestBinaryPath) 513 | if err := os.Remove(latestBinaryPath); err != nil { 514 | log.Errorf("removing binary file %q: %v", latestBinaryPath, err) 515 | } 516 | } 517 | latestBinaryPath = newBinaryPath 518 | rerun(ctx) 519 | }) 520 | case <-ctx.Done(): 521 | stopServer() 522 | return 523 | } 524 | } 525 | } 526 | 527 | type FileChangeHandler struct { 528 | binaryOutBasePath string 529 | watchBasePath string 530 | customWatchers []customWatcher 531 | stateTracker *statetrack.Tracker 532 | reload *broadcaster.SignalBroadcaster 533 | debounced func(fn func()) 534 | conf *config.Config 535 | templGoFileRegistry *templgofilereg.Comparer 536 | buildRunner *ctxrun.Runner 537 | } 538 | 539 | func (h *FileChangeHandler) Handle(ctx context.Context, e fsnotify.Event) error { 540 | switch e.Op { 541 | case fsnotify.Chmod: 542 | log.Debugf("ignoring file operation (%s): %q", e.Op.String(), e.Name) 543 | return nil // Ignore chmod events. 544 | case fsnotify.Remove: 545 | // No need to check for _templ.go suffix, Remove is a no-op for other files. 546 | h.templGoFileRegistry.Remove(e.Name) 547 | } 548 | 549 | if h.conf.Log.ClearOn == config.LogClearOnFileChange { 550 | log.ClearLogs() 551 | } 552 | 553 | relativeFileName, err := filepath.Rel(h.watchBasePath, e.Name) 554 | if err != nil { 555 | panic(fmt.Errorf( 556 | "determining relative path for %q with base path %q", 557 | e.Name, h.conf.App.DirWork, 558 | )) 559 | } 560 | 561 | log.Debugf("handling file operation (%s): %q", e.Op.String(), relativeFileName) 562 | 563 | if h.conf.Format { 564 | if strings.HasSuffix(e.Name, ".templ") { 565 | log.Debugf("format templ file %s", e.Name) 566 | err := cmdrun.RunTemplFmt(context.Background(), h.conf.App.DirWork, e.Name) 567 | if err != nil { 568 | log.Errorf("templ formatting error: %v", err) 569 | } 570 | } 571 | } 572 | 573 | var wg sync.WaitGroup 574 | var customWatcherTriggered atomic.Bool 575 | var act action.SyncStatus 576 | 577 | if len(h.customWatchers) > 0 { 578 | // Each custom watcher will be executed in the goroutine of its debouncer. 579 | wg.Add(len(h.customWatchers)) 580 | for i, w := range h.customWatchers { 581 | if !w.isFilePathIncluded(relativeFileName) { 582 | // File doesn't match any glob 583 | wg.Done() 584 | continue 585 | } 586 | 587 | customWatcherTriggered.Store(true) 588 | index := i 589 | w.debounced(func() { // This runs in a separate goroutine. 590 | defer wg.Done() 591 | start := time.Now() 592 | defer func() { log.Durf(string(w.name), time.Since(start)) }() 593 | if w.cmd != "" { 594 | o, err := cmdrun.Sh(ctx, h.conf.App.DirWork, string(w.cmd)) 595 | output := string(o) 596 | if errors.Is(err, cmdrun.ErrExitCode1) { 597 | if w.failOnErr { 598 | h.stateTracker.Set( 599 | statetrack.IndexOffsetCustomWatcher+index, output, 600 | ) 601 | h.reload.BroadcastNonblock() 602 | } else { 603 | // Log the error when fail-on-error is disabled. 604 | log.Errorf( 605 | "custom watcher %q exited with code 1: %s", 606 | w.cmd, output, 607 | ) 608 | } 609 | return 610 | } else if err != nil { 611 | // The reason this cmd failed was not just exit code 1. 612 | if w.failOnErr { 613 | h.stateTracker.Set( 614 | statetrack.IndexOffsetCustomWatcher+index, output, 615 | ) 616 | } 617 | log.Errorf( 618 | "executing custom watcher %q: %s", 619 | w.cmd, output, 620 | ) 621 | } 622 | } 623 | h.stateTracker.Set(statetrack.IndexOffsetCustomWatcher+index, "") 624 | act.Require(w.requires) 625 | }) 626 | } 627 | } 628 | 629 | wg.Wait() // Wait for all custom watcher to finish before attempting reload. 630 | if customWatcherTriggered.Load() { 631 | // Custom watcher was triggered, apply custom action. 632 | switch act.Load() { 633 | case action.ActionNone: 634 | // Custom watchers require no further action to be taken. 635 | log.Debugf("custom watchers: no action") 636 | return nil 637 | case action.ActionReload: 638 | // Custom watchers require just a reload of all browser tabs. 639 | log.Debugf("custom watchers: notify reload") 640 | h.reload.BroadcastNonblock() 641 | return nil 642 | case action.ActionRestart: 643 | // Custom watchers require just a server restart. 644 | log.Debugf("custom watchers: rerun app server") 645 | chRerunServer <- struct{}{} 646 | return nil 647 | default: 648 | log.Debugf("custom watchers: rebuild app server") 649 | } 650 | } else { 651 | log.Debugf("custom watchers: no watcher triggered") 652 | if strings.HasSuffix(e.Name, "_templ.txt") { 653 | log.Debugf("ignore change in generated templ txt: %s", e.Name) 654 | return nil 655 | } 656 | if strings.HasSuffix(e.Name, ".templ") { 657 | log.Debugf("ignore templ file change: %s", e.Name) 658 | return nil 659 | } 660 | // No custom watcher triggered, follow default pipeline. 661 | if h.stateTracker.Get(statetrack.IndexTempl) != "" { 662 | // A templ template is broken, don't continue. 663 | return nil 664 | } 665 | if strings.HasSuffix(e.Name, "_templ.go") { 666 | if recompile, err := h.templGoFileRegistry.Compare(e.Name); err != nil { 667 | log.Errorf("checking generated templ go file: %v", err) 668 | return nil 669 | } else if !recompile { 670 | log.Debugf("_templ.go change doesn't require recompilation") 671 | 672 | // Don't reload browser tabs while the app server is restarting. 673 | if !rerunActive.Load() { 674 | // Reload browser tabs when a _templ.go file has changed without 675 | // changing its code structure (load from _templ.txt is possible). 676 | h.reload.BroadcastNonblock() 677 | } 678 | return nil 679 | } else { 680 | log.Debugf("change in _templ.go requires recompilation") 681 | } 682 | } 683 | } 684 | 685 | h.debounced(func() { 686 | log.TemplierFileChange(e) 687 | h.buildRunner.Go(context.Background(), func(ctx context.Context) { 688 | newBinaryPath := lintAndBuildServer( 689 | ctx, h.stateTracker, h.conf, h.binaryOutBasePath, 690 | ) 691 | if h.stateTracker.ErrIndex() != -1 { 692 | h.reload.BroadcastNonblock() 693 | // Don't restart the server if there was any error. 694 | return 695 | } 696 | 697 | chRunNewServer <- newBinaryPath 698 | }) 699 | }) 700 | return nil 701 | } 702 | 703 | func runGolangCILint(ctx context.Context, st *statetrack.Tracker, conf *config.Config) { 704 | startLinting := time.Now() 705 | buf, err := cmdrun.Run( 706 | ctx, conf.App.DirWork, nil, 707 | "golangci-lint", "run", conf.App.DirSrcRoot+"/...", 708 | ) 709 | if errors.Is(ctx.Err(), context.Canceled) { 710 | log.Debugf("golangci-lint cmd aborted") 711 | return // No need to check errors and continue. 712 | } 713 | if errors.Is(err, cmdrun.ErrExitCode1) { 714 | bufStr := string(buf) 715 | log.Error(bufStr) 716 | st.Set(statetrack.IndexGolangciLint, bufStr) 717 | return 718 | } else if err != nil { 719 | log.Errorf("failed running golangci-lint: %v", err) 720 | return 721 | } 722 | st.Set(statetrack.IndexGolangciLint, "") 723 | log.Durf("linted", time.Since(startLinting)) 724 | } 725 | 726 | func buildServer( 727 | ctx context.Context, st *statetrack.Tracker, conf *config.Config, outBasePath string, 728 | ) (newBinaryPath string) { 729 | startBuilding := time.Now() 730 | 731 | binaryPath := makeUniqueServerOutPath(outBasePath) 732 | 733 | args := append([]string{"build"}, conf.CompilerFlags()...) 734 | args = append(args, "-o", binaryPath, conf.App.DirCmd) 735 | buf, err := cmdrun.Run(ctx, conf.App.DirWork, conf.CompilerEnv(), "go", args...) 736 | if errors.Is(ctx.Err(), context.Canceled) { 737 | log.Debugf("go build cmd aborted") 738 | return // No need to check errors and continue. 739 | } 740 | if err != nil { 741 | bufStr := string(buf) 742 | log.Error(bufStr) 743 | st.Set(statetrack.IndexGo, bufStr) 744 | return 745 | } 746 | // Reset the process exit and go compiler errors 747 | st.Set(statetrack.IndexGo, "") 748 | st.Set(statetrack.IndexExit, "") 749 | log.Durf("compiled cmd/server", time.Since(startBuilding)) 750 | return binaryPath 751 | } 752 | 753 | func makeUniqueServerOutPath(basePath string) string { 754 | tm := time.Now() 755 | return filepath.Join(basePath, "server_"+strconv.FormatInt(tm.UnixNano(), 16)) 756 | } 757 | 758 | func lintAndBuildServer( 759 | ctx context.Context, st *statetrack.Tracker, conf *config.Config, outBasePath string, 760 | ) (newBinaryPath string) { 761 | if st.ErrIndex() == statetrack.IndexTempl { 762 | return 763 | } 764 | var wg sync.WaitGroup 765 | if conf.Lint { 766 | wg.Add(1) 767 | go func() { 768 | defer wg.Done() 769 | runGolangCILint(ctx, st, conf) 770 | }() 771 | } 772 | wg.Add(1) 773 | go func() { 774 | defer wg.Done() 775 | newBinaryPath = buildServer(ctx, st, conf, outBasePath) 776 | if newBinaryPath != "" { 777 | log.Debugf("new app server binary: %s", newBinaryPath) 778 | } 779 | }() 780 | wg.Wait() // Wait for build and lint to finish. 781 | return newBinaryPath 782 | } 783 | 784 | func checkTemplVersion(ctx context.Context) error { 785 | out, err := cmdrun.Run(ctx, "", nil, "templ", "version") 786 | if err != nil { 787 | return err 788 | } 789 | outStr := strings.TrimSpace(string(out)) 790 | if !strings.HasPrefix(outStr, config.SupportedTemplVersion) { 791 | log.WarnUnsupportedTemplVersion( 792 | config.Version, config.SupportedTemplVersion, outStr, 793 | ) 794 | } 795 | return nil 796 | } 797 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "testing" 6 | 7 | "github.com/romshark/templier/internal/config" 8 | "github.com/romshark/yamagiconf" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | //go:embed example-config.yml 13 | var exampleConfig string 14 | 15 | func TestExampleConfig(t *testing.T) { 16 | var c config.Config 17 | 18 | err := yamagiconf.Load(exampleConfig, &c) 19 | require.NoError(t, err) 20 | } 21 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package main 4 | 5 | import _ "github.com/a-h/templ/cmd/templ" 6 | --------------------------------------------------------------------------------