Skip to content

Commit 4f61122

Browse files
committed
feat: add before_build and after_build hooks to dev server
Add optional hooks that run around each rebuild cycle in the dev server. Hooks return Result(Nil, String) so they can abort the rebuild with a descriptive error message (e.g. a failing Tailwind compilation). Execution order: before_build → build command → after_build → SSE reload. A failing before_build aborts the build entirely; a failing after_build prevents the browser reload. Both error paths log the reason and keep the server running. Internal changes: - Introduce RebuildStateConfig public type to bundle build_command and hooks into rebuild_actor.new() - Refactor rebuild() to use Result with `use _ <- result.try` for clean short-circuit chaining via run_hook() and exec_build() helpers - Update existing tests for the new RebuildStateConfig API - Add 7 new tests covering hook invocation, ordering, and error handling - Document hooks in docs/dev-server.md (API, reference table, rebuild flow)
1 parent 5f8a923 commit 4f61122

8 files changed

Lines changed: 397 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
# Changelog
22

3+
## 5.0.0
4+
5+
Released on 2026-03-18
6+
7+
### Added
8+
9+
- add before_build and after_build hooks to dev server
10+
> Add optional hooks that run around each rebuild cycle in the dev server.
11+
> Hooks return Result(Nil, String) so they can abort the rebuild with a
12+
> descriptive error message (e.g. a failing Tailwind compilation).
13+
>
14+
> Execution order: before_build → build command → after_build → SSE reload.
15+
> A failing before_build aborts the build entirely; a failing after_build
16+
> prevents the browser reload. Both error paths log the reason and keep
17+
> the server running.
18+
>
19+
> Internal changes:
20+
> - Introduce RebuildStateConfig public type to bundle build_command and
21+
> hooks into rebuild_actor.new()
22+
> - Refactor rebuild() to use Result with `use _ <- result.try` for clean
23+
> short-circuit chaining via run_hook() and exec_build() helpers
24+
> - Update existing tests for the new RebuildStateConfig API
25+
> - Add 7 new tests covering hook invocation, ordering, and error handling
26+
> - Document hooks in docs/dev-server.md (API, reference table, rebuild flow)
27+
28+
### CI
29+
30+
- gleam 1.15.1
31+
332
## 4.0.2
433

534
Released on 2026-03-17

docs/dev-server.md

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,14 @@ The server starts, performs an initial build, then watches for changes:
9999
```text
100100
🔨 blogatto dev server starting...
101101
👀 Watching for file changes...
102-
👀 Watching: ./static
103-
👀 Watching: ./src/
104-
👀 Watching: ./blog
102+
👀 Watching: ./static
103+
👀 Watching: ./src/
104+
👀 Watching: ./blog
105105
106106
⟳ Rebuilding...
107107
✓ Rebuild complete
108-
→ http://127.0.0.1:3000
109-
Output: ./dist
108+
→ http://127.0.0.1:3000
109+
Output: ./dist
110110
```
111111

112112
Open `http://127.0.0.1:3000` in your browser. When you save a file, the site rebuilds and the browser reloads automatically.
@@ -122,6 +122,14 @@ blog.config()
122122
|> dev.port(8080)
123123
|> dev.host("0.0.0.0")
124124
|> dev.live_reload(False)
125+
|> dev.before_build(fn() {
126+
io.println("Starting build...")
127+
Ok(Nil)
128+
})
129+
|> dev.after_build(fn() {
130+
io.println("Build finished!")
131+
Ok(Nil)
132+
})
125133
|> dev.start()
126134
```
127135

@@ -182,6 +190,37 @@ dev.new(config)
182190
|> dev.live_reload(False)
183191
```
184192

193+
### `dev.before_build(server, hook)`
194+
195+
Set a function to run **before** each rebuild. The hook runs before the build command is executed, on every rebuild (including the initial build on startup). This is useful for setup or cleanup tasks.
196+
197+
The hook must return `Result(Nil, String)`. If it returns `Error(reason)`, the build is aborted and the error reason is logged. The hook runs regardless of whether the subsequent build would succeed or fail.
198+
199+
```gleam
200+
dev.new(config)
201+
|> dev.before_build(fn() {
202+
// Clean generated assets, fetch data, etc.
203+
io.println("Preparing build...")
204+
Ok(Nil)
205+
})
206+
```
207+
208+
### `dev.after_build(server, hook)`
209+
210+
Set a function to run **after** each successful rebuild. The hook runs only when the build command exits with code 0. This is useful for post-processing tasks like running Tailwind CSS, copying additional assets, or sending notifications.
211+
212+
The hook must return `Result(Nil, String)`. If it returns `Error(reason)`, the error is logged and browsers are **not** reloaded. The hook is **not** called when the build fails.
213+
214+
```gleam
215+
dev.new(config)
216+
|> dev.after_build(fn() {
217+
case shellout.command("npx", ["tailwindcss", "-o", "./dist/style.css"], ".", []) {
218+
Ok(_) -> Ok(Nil)
219+
Error(_) -> Error("Tailwind CSS compilation failed")
220+
}
221+
})
222+
```
223+
185224
## Reference
186225

187226
| Option | Default | Description |
@@ -190,6 +229,8 @@ dev.new(config)
190229
| `port` | `3000` | HTTP server port |
191230
| `host` | `"127.0.0.1"` | Bind address |
192231
| `live_reload` | `True` | Inject live-reload script into HTML responses |
232+
| `before_build` | `None` | `fn() -> Result(Nil, String)` to run before each rebuild |
233+
| `after_build` | `None` | `fn() -> Result(Nil, String)` to run after each successful rebuild |
193234

194235
## How it works
195236

@@ -206,9 +247,10 @@ The dev server is built on OTP actors:
206247
1. A file changes on disk
207248
2. The file watcher sends a `FileChanged` message to the rebuild actor
208249
3. The rebuild actor cancels any pending debounce timer and starts a new 300ms timer
209-
4. When the timer fires, the actor shells out to the build command
210-
5. On success (exit code 0), a `Reload` event is sent to all connected SSE clients
211-
6. On failure, the error output is logged and the server keeps running with the last successful build
250+
4. When the timer fires, the `before_build` hook runs (if configured)
251+
5. The actor shells out to the build command
252+
6. On success (exit code 0), the `after_build` hook runs (if configured), then a `Reload` event is sent to all connected SSE clients
253+
7. On failure, the error output is logged and the server keeps running with the last successful build (the `after_build` hook is **not** called)
212254

213255
### Watched directories
214256

examples/simple_blog/manifest.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# You typically do not need to edit this file
33

44
packages = [
5-
{ name = "blogatto", version = "4.0.2", build_tools = ["gleam"], requirements = ["filepath", "filespy", "frontmatter", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_time", "gtempo", "houdini", "lustre", "marceau", "maud", "mist", "mork", "simplifile", "smalto", "smalto_lustre", "str", "webls"], source = "local", path = "../.." },
5+
{ name = "blogatto", version = "5.0.0", build_tools = ["gleam"], requirements = ["filepath", "filespy", "frontmatter", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_time", "gtempo", "houdini", "lustre", "marceau", "maud", "mist", "mork", "simplifile", "smalto", "smalto_lustre", "str", "webls"], source = "local", path = "../.." },
66
{ name = "casefold", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "casefold", source = "hex", outer_checksum = "F09530B6F771BB7B0BCACD3014089C20DFDA31775BA4793266C3814607C0A468" },
77
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
88
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },

gleam.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name = "blogatto"
2-
version = "4.0.2"
2+
version = "5.0.0"
33
description = "A Gleam framework for building static blogs with Lustre and Markdown. Generates HTML pages, RSS feeds, sitemaps, and robots.txt from markdown files with frontmatter, with multilingual support."
44
repository = { type = "github", user = "veeso", repo = "blogatto" }
55
licenses = ["MIT"]

src/blogatto/dev.gleam

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ import gleam/erlang/process
99
import gleam/int
1010
import gleam/io
1111
import gleam/list
12-
import gleam/option
12+
import gleam/option.{type Option}
1313
import gleam/result
1414
import gleam/string
1515

1616
/// A development server for Blogatto. This will serve the generated blog and watch for file changes to trigger rebuilds.
1717
pub type DevServer(msg) {
1818
DevServer(
19+
/// An optional function to run after each build.
20+
/// This can be used to perform any tasks after the build command is run, such as running tailwind or other post-build tasks. (default: None)
21+
after_build: Option(fn() -> Result(Nil, String)),
22+
/// An optional function to run before each build.
23+
/// This can be used to perform any setup or cleanup tasks before the build command is run. (default: None)
24+
before_build: Option(fn() -> Result(Nil, String)),
1925
/// Build command to build the blog. (default: "gleam run")
2026
build_command: String,
2127
/// Blogatto build config to use for knowing which files to watch.
@@ -32,6 +38,8 @@ pub type DevServer(msg) {
3238
/// Create a new development server with the given config and options.
3339
pub fn new(config: config.Config(msg)) -> DevServer(msg) {
3440
DevServer(
41+
after_build: option.None,
42+
before_build: option.None,
3543
build_command: "gleam run",
3644
config:,
3745
port: 3000,
@@ -40,6 +48,24 @@ pub fn new(config: config.Config(msg)) -> DevServer(msg) {
4048
)
4149
}
4250

51+
/// Set a function to run after each build.
52+
/// This can be used to perform any tasks after the build command is run, such as running tailwind or other post-build tasks.
53+
pub fn after_build(
54+
server: DevServer(msg),
55+
after_build: fn() -> Result(Nil, String),
56+
) -> DevServer(msg) {
57+
DevServer(..server, after_build: option.Some(after_build))
58+
}
59+
60+
/// Set a function to run before each build.
61+
/// This can be used to perform any setup or cleanup tasks before the build command is run.
62+
pub fn before_build(
63+
server: DevServer(msg),
64+
before_build: fn() -> Result(Nil, String),
65+
) -> DevServer(msg) {
66+
DevServer(..server, before_build: option.Some(before_build))
67+
}
68+
4369
/// Set the build command to use for building the blog.
4470
/// This should be a command that can be run in the terminal to build the blog,
4571
/// such as "gleam run" or "make build".
@@ -76,7 +102,12 @@ pub fn start(server: DevServer(msg)) -> Result(Nil, error.BlogattoError) {
76102

77103
// Start the rebuild actor (performs an initial build on startup)
78104
use started <- result.try(
79-
rebuild_actor.new(server.build_command)
105+
rebuild_actor.RebuildStateConfig(
106+
build_command: server.build_command,
107+
before_build: server.before_build,
108+
after_build: server.after_build,
109+
)
110+
|> rebuild_actor.new
80111
|> result.map_error(fn(e) {
81112
error.DevServer("Failed to start rebuild actor: " <> string.inspect(e))
82113
}),

src/blogatto/internal/dev/rebuild_actor.gleam

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@ import gleam/io
88
import gleam/list
99
import gleam/option.{type Option}
1010
import gleam/otp/actor
11+
import gleam/result
1112

1213
const debounce_delay: Int = 300
1314

15+
/// Configuration for the rebuild actor, including the build command and optional hooks to run before and after the build.
16+
pub type RebuildStateConfig {
17+
RebuildStateConfig(
18+
build_command: String,
19+
before_build: Option(fn() -> Result(Nil, String)),
20+
after_build: Option(fn() -> Result(Nil, String)),
21+
)
22+
}
23+
1424
type RebuildState {
1525
RebuildState(
1626
self: Subject(message.RebuildMessage),
1727
build_command: String,
28+
before_build: Option(fn() -> Result(Nil, String)),
29+
after_build: Option(fn() -> Result(Nil, String)),
1830
debounce_timer: Option(Timer),
1931
sse_clients: List(Subject(message.SseMessage)),
2032
)
@@ -42,14 +54,16 @@ pub fn register_sse_client(
4254
/// for file change messages, debounce them, shell out to the build command,
4355
/// and broadcast reload events to connected SSE clients.
4456
pub fn new(
45-
build_command: String,
57+
config: RebuildStateConfig,
4658
) -> Result(actor.Started(Subject(message.RebuildMessage)), actor.StartError) {
4759
actor.new_with_initialiser(5000, fn(subject) {
4860
// Schedule an immediate rebuild on startup
4961
process.send(subject, message.Rebuild)
5062
RebuildState(
5163
self: subject,
52-
build_command:,
64+
build_command: config.build_command,
65+
before_build: config.before_build,
66+
after_build: config.after_build,
5367
debounce_timer: option.None,
5468
sse_clients: [],
5569
)
@@ -78,9 +92,9 @@ fn handle_message(
7892
message.Rebuild -> {
7993
// Prune dead SSE clients, then broadcast reload on success
8094
let live_clients = prune_dead_clients(state.sse_clients)
81-
case rebuild(state.build_command) {
82-
True -> broadcast_reload(live_clients)
83-
False -> Nil
95+
case rebuild(state.build_command, state.before_build, state.after_build) {
96+
Ok(_) -> broadcast_reload(live_clients)
97+
Error(_) -> Nil
8498
}
8599
actor.continue(
86100
RebuildState(
@@ -101,21 +115,50 @@ fn handle_message(
101115
}
102116
}
103117

104-
fn rebuild(build_command: String) -> Bool {
118+
fn rebuild(
119+
build_command: String,
120+
before_build: Option(fn() -> Result(Nil, String)),
121+
after_build: Option(fn() -> Result(Nil, String)),
122+
) -> Result(Nil, Nil) {
123+
use _ <- result.try(run_hook(before_build, "before_build"))
124+
use _ <- result.try(exec_build(build_command))
125+
use _ <- result.try(run_hook(after_build, "after_build"))
126+
Ok(Nil)
127+
}
128+
129+
fn run_hook(
130+
hook: Option(fn() -> Result(Nil, String)),
131+
name: String,
132+
) -> Result(Nil, Nil) {
133+
case hook {
134+
option.Some(f) ->
135+
case f() {
136+
Ok(Nil) -> Ok(Nil)
137+
Error(msg) -> {
138+
io.println("✗ " <> name <> " hook failed: " <> msg)
139+
io.println(" (server still running, fix the error and save again)")
140+
Error(Nil)
141+
}
142+
}
143+
option.None -> Ok(Nil)
144+
}
145+
}
146+
147+
fn exec_build(build_command: String) -> Result(Nil, Nil) {
105148
io.println("⟳ Rebuilding...")
106149
let #(exit_code, output) = command.exec(build_command)
107150
case exit_code {
108151
0 -> {
109152
io.println("✓ Rebuild complete")
110-
True
153+
Ok(Nil)
111154
}
112155
_ -> {
113156
io.println(
114157
"✗ Build failed (exit code " <> int.to_string(exit_code) <> "):",
115158
)
116159
io.println(output)
117160
io.println(" (server still running, fix the error and save again)")
118-
False
161+
Error(Nil)
119162
}
120163
}
121164
}

0 commit comments

Comments
 (0)