Skip to content

Commit 07008c0

Browse files
committed
cmd/scriggo: add LiveReload for automatic page reloads
This change adds LiveReload functionality, enabling automatic page reloads in the browser whenever the template code changes. The 'scriggo serve' command now injects JavaScript into the generated page. Using Server-Side Events (SSE), it triggers a page reload whenever the page’s template files change. LiveReload can be disabled with the --disable-livereload flag.
1 parent a8eb220 commit 07008c0

File tree

4 files changed

+255
-11
lines changed

4 files changed

+255
-11
lines changed

cmd/scriggo/escapers.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright 2019 The Scriggo Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"io"
9+
)
10+
11+
// jsStringEscapes contains the runes that must be escaped when placed within
12+
// a JavaScript and JSON string with single or double quotes, in addition to
13+
// the runes U+2028 and U+2029.
14+
var jsStringEscapes = [...]string{
15+
0: `\u0000`,
16+
1: `\u0001`,
17+
2: `\u0002`,
18+
3: `\u0003`,
19+
4: `\u0004`,
20+
5: `\u0005`,
21+
6: `\u0006`,
22+
7: `\u0007`,
23+
'\b': `\b`,
24+
'\t': `\t`,
25+
'\n': `\n`,
26+
'\v': `\u000b`,
27+
'\f': `\f`,
28+
'\r': `\r`,
29+
14: `\u000e`,
30+
15: `\u000f`,
31+
16: `\u0010`,
32+
17: `\u0011`,
33+
18: `\u0012`,
34+
19: `\u0013`,
35+
20: `\u0014`,
36+
21: `\u0015`,
37+
22: `\u0016`,
38+
23: `\u0017`,
39+
24: `\u0018`,
40+
25: `\u0019`,
41+
26: `\u001a`,
42+
27: `\u001b`,
43+
28: `\u001c`,
44+
29: `\u001d`,
45+
30: `\u001e`,
46+
31: `\u001f`,
47+
'"': `\"`,
48+
'&': `\u0026`,
49+
'\'': `\u0027`,
50+
'<': `\u003c`,
51+
'>': `\u003e`,
52+
'\\': `\\`,
53+
}
54+
55+
// jsStringEscape escapes the string s so it can be placed within a JavaScript
56+
// and JSON string with single or double quotes, and write it to w.
57+
func jsStringEscape(w io.Writer, s string) {
58+
last := 0
59+
for i, c := range s {
60+
var esc string
61+
switch {
62+
case int(c) < len(jsStringEscapes):
63+
esc = jsStringEscapes[c]
64+
case c == '\u2028':
65+
esc = `\u2028`
66+
case c == '\u2029':
67+
esc = `\u2029`
68+
}
69+
if esc == "" {
70+
continue
71+
}
72+
if last != i {
73+
_, _ = io.WriteString(w, s[last:i])
74+
}
75+
_, _ = io.WriteString(w, esc)
76+
if c == '\u2028' || c == '\u2029' {
77+
last = i + 3
78+
} else {
79+
last = i + 1
80+
}
81+
}
82+
if last != len(s) {
83+
_, _ = io.WriteString(w, s[last:])
84+
}
85+
}

cmd/scriggo/help.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ Examples:
195195
`
196196

197197
const helpServe = `
198-
usage: scriggo serve [-S n] [--metrics]
198+
usage: scriggo serve [-S n] [--metrics] [--disable-livereload]
199199
200200
Serve runs a web server and serves the template rooted at the current
201201
directory. It is useful to learn Scriggo templates.
@@ -218,7 +218,8 @@ it renders 'blog/index.html' or 'blog/index.md'.
218218
Markdown is converted to HTML with the Goldmark parser with the options
219219
html.WithUnsafe, parser.WithAutoHeadingID and extension.GFM.
220220
221-
Templates are automatically rebuilt when a file changes.
221+
When a file is modified, the server automatically rebuilds templates, and the
222+
browser reloads the page.
222223
223224
The -S flag prints the assembly code of the served file and n determines the
224225
maximum length, in runes, of disassembled Text instructions
@@ -228,6 +229,9 @@ maximum length, in runes, of disassembled Text instructions
228229
n < 0: all text
229230
230231
The --metrics flags prints metrics about execution time.
232+
233+
The --disable-livereload flag disables LiveReload, preventing automatic page
234+
reloads in the browser.
231235
`
232236

233237
const helpScriggofile = `

cmd/scriggo/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ var commands = map[string]func(){
238238
flag.Usage = commandsHelp["serve"]
239239
s := flag.Int("S", 0, "print assembly listing. n determines the length of Text instructions.")
240240
metrics := flag.Bool("metrics", false, "print metrics about file executions.")
241+
disableLiveReload := flag.Bool("disable-livereload", false, "disable LiveReload.")
241242
flag.Parse()
242243
asm := -2 // -2: no assembler
243244
flag.Visit(func(f *flag.Flag) {
@@ -248,7 +249,7 @@ var commands = map[string]func(){
248249
}
249250
}
250251
})
251-
err := serve(asm, *metrics)
252+
err := serve(asm, *metrics, *disableLiveReload)
252253
if err != nil {
253254
exitError("%s", err)
254255
}

cmd/scriggo/serve.go

Lines changed: 162 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ import (
3030
// directory. metrics reports whether print the metrics. If asm is -1 or
3131
// greater, serve prints the assembly code of the served file and the value of
3232
// asm determines the maximum length, in runes, of disassembled Text
33-
// instructions
33+
// instructions. If disableLiveReload is true, it disables LiveReload.
3434
//
3535
// asm > 0: at most asm runes; leading and trailing white space are removed
3636
// asm == 0: no text
3737
// asm == -1: all text
38-
func serve(asm int, metrics bool) error {
38+
func serve(asm int, metrics bool, disableLiveReload bool) error {
3939

4040
fsys, err := newTemplateFS(".")
4141
if err != nil {
@@ -59,6 +59,9 @@ func serve(asm int, metrics bool) error {
5959
templatesDependencies: map[string]map[string]struct{}{},
6060
asm: asm,
6161
}
62+
if !disableLiveReload {
63+
srv.liveReloads = map[*liveReload]struct{}{}
64+
}
6265
if metrics {
6366
srv.metrics.active = true
6467
srv.metrics.header = true
@@ -68,27 +71,35 @@ func serve(asm int, metrics bool) error {
6871
select {
6972
case name := <-fsys.Changed():
7073
srv.Lock()
74+
invalidated := map[string]bool{}
7175
if _, ok := srv.templates[name]; ok {
7276
delete(srv.templates, name)
7377
for _, dependents := range srv.templatesDependencies {
7478
delete(dependents, name)
7579
}
80+
invalidated[name] = true
7681
} else {
77-
var invalidatedFiles []string
7882
for dependency, dependents := range srv.templatesDependencies {
7983
if dependency == name {
8084
for d := range dependents {
8185
delete(srv.templates, d)
82-
invalidatedFiles = append(invalidatedFiles, d)
86+
invalidated[d] = true
8387
}
8488
}
8589
}
86-
for _, invalidated := range invalidatedFiles {
90+
for invalidated := range invalidated {
8791
for _, dependents := range srv.templatesDependencies {
8892
delete(dependents, invalidated)
8993
}
9094
}
9195
}
96+
if len(invalidated) > 0 {
97+
for r := range srv.liveReloads {
98+
if invalidated[r.file+".html"] || invalidated[r.file+".md"] {
99+
go func() { r.reload() }()
100+
}
101+
}
102+
}
92103
srv.Unlock()
93104
case err := <-fsys.Errors:
94105
srv.logf("%v", err)
@@ -120,13 +131,83 @@ type server struct {
120131
sync.Mutex
121132
templates map[string]*scriggo.Template
122133
templatesDependencies map[string]map[string]struct{}
134+
liveReloads map[*liveReload]struct{}
123135
metrics struct {
124136
active bool
125137
header bool
126138
}
127139
}
128140

129141
func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
142+
if r.Header.Get("Accept") == "text/event-stream" {
143+
srv.serveLiveReload(w, r)
144+
return
145+
}
146+
srv.serveTemplate(w, r)
147+
}
148+
149+
type liveReload struct {
150+
file string // file path without extension
151+
152+
sync.Mutex
153+
w io.Writer
154+
}
155+
156+
var reload = []byte("data: reload\n\n")
157+
158+
func (lr *liveReload) reload() {
159+
lr.Lock()
160+
_, _ = lr.w.Write(reload)
161+
lr.w.(http.Flusher).Flush()
162+
lr.Unlock()
163+
}
164+
165+
func (srv *server) serveLiveReload(w http.ResponseWriter, r *http.Request) {
166+
167+
if _, ok := w.(http.Flusher); !ok || srv.liveReloads == nil {
168+
http.Error(w, "Live reload is not supported", http.StatusUnsupportedMediaType)
169+
return
170+
}
171+
172+
file := r.URL.Path[1:]
173+
if file == "" || strings.HasSuffix(file, "/") {
174+
file += "index"
175+
}
176+
if ext := path.Ext(file); ext != "" {
177+
file = file[:len(file)-len(ext)]
178+
}
179+
180+
lr := &liveReload{
181+
file: file,
182+
w: w,
183+
}
184+
185+
w.Header().Set("Content-Type", "text/event-stream")
186+
w.Header().Set("Cache-Control", "no-cache")
187+
w.Header().Set("Connection", "keep-alive")
188+
w.WriteHeader(200)
189+
190+
srv.Lock()
191+
srv.liveReloads[lr] = struct{}{}
192+
_, ok := srv.templates[file+".html"]
193+
if !ok {
194+
_, ok = srv.templates[file+".md"]
195+
}
196+
srv.Unlock()
197+
198+
if !ok {
199+
lr.reload()
200+
}
201+
202+
<-r.Context().Done()
203+
204+
srv.Lock()
205+
delete(srv.liveReloads, lr)
206+
srv.Unlock()
207+
208+
}
209+
210+
func (srv *server) serveTemplate(w http.ResponseWriter, r *http.Request) {
130211

131212
name := r.URL.Path[1:]
132213
if name == "" || strings.HasSuffix(name, "/") {
@@ -221,10 +302,32 @@ func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
221302
}
222303
runTime := time.Since(start)
223304

305+
s := b.Bytes()
306+
i := indexEndBody(s)
307+
if i == -1 {
308+
i = len(s)
309+
}
224310
w.Header().Set("Content-Type", "text/html; charset=utf-8")
225-
_, err = b.WriteTo(w)
226-
if err != nil {
227-
srv.logf("%s", err)
311+
if srv.liveReloads == nil {
312+
_, _ = w.Write(s)
313+
} else {
314+
_, _ = w.Write(s[:i])
315+
_, _ = io.WriteString(w, `<script>
316+
// Scriggo LiveReload.
317+
(function () {
318+
if (typeof EventSource !== 'function') return;
319+
const es = new EventSource('`)
320+
jsStringEscape(w, r.URL.Path)
321+
_, _ = io.WriteString(w, `');
322+
es.onmessage = function(e) {
323+
if (e.data === 'reload') {
324+
es.close();
325+
location.reload();
326+
}
327+
};
328+
})();
329+
</script>`)
330+
_, _ = w.Write(s[i:])
228331
}
229332

230333
if srv.metrics.active {
@@ -269,3 +372,54 @@ func (srv *server) logf(format string, a ...interface{}) {
269372
println()
270373
srv.metrics.header = true
271374
}
375+
376+
// indexEndBody locates the closing "</body>" tag in s, ignoring case and
377+
// permitting spaces before '>'. If the tag is found, the function returns its
378+
// index, unless the tag's line contains only whitespaces before it; in that
379+
// case, it returns the index of the newline ('\n') at the start of the line.
380+
// If no closing tag is found, it returns -1.
381+
func indexEndBody(s []byte) int {
382+
for {
383+
i := bytes.LastIndex(s, []byte("</"))
384+
if i == -1 {
385+
return -1
386+
}
387+
if isBodyTag(s[i+2:]) {
388+
for j := i - 1; j >= 0; j-- {
389+
switch s[j] {
390+
case ' ', '\t', '\r':
391+
continue
392+
case '\n':
393+
if j > 0 && s[j-1] == '\r' {
394+
j--
395+
}
396+
return j
397+
}
398+
break
399+
}
400+
return i
401+
}
402+
s = s[:i]
403+
}
404+
}
405+
406+
// isBodyTag checks if s starts with "body>", ignoring case and allowing spaces
407+
// before '>'.
408+
func isBodyTag(s []byte) bool {
409+
if len(s) < 5 {
410+
return false
411+
}
412+
if !bytes.EqualFold(s[:4], []byte("body")) {
413+
return false
414+
}
415+
for i := 4; i < len(s); i++ {
416+
switch s[i] {
417+
case '>':
418+
return true
419+
case ' ', '\t', '\n', '\r':
420+
continue
421+
}
422+
break
423+
}
424+
return false
425+
}

0 commit comments

Comments
 (0)