Add live reload to build command (#7)

This commit is contained in:
Facundo Olano 2024-02-19 18:03:06 -03:00 committed by GitHub
parent 6c8fd6aed2
commit 2d84e1f2cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 258 additions and 71 deletions

View file

@ -6,77 +6,83 @@ import (
"net/http"
"os"
"path/filepath"
"sync/atomic"
"github.com/facundoolano/jorge/config"
"github.com/facundoolano/jorge/site"
"github.com/fsnotify/fsnotify"
)
// Generate and serve the site, rebuilding when the source files change.
// Generate and serve the site, rebuilding when the source files change
// and triggering a page refresh on clients browsing it.
func Serve(rootDir string) error {
config, err := config.LoadDevServer(rootDir)
config, err := config.LoadDev(rootDir)
if err != nil {
return err
}
if err := rebuild(config); err != nil {
if err := rebuildSite(config); err != nil {
return err
}
// watch for changes in src and layouts, and trigger a rebuild
watcher, err := setupWatcher(config)
watcher, broker, err := setupWatcher(config)
if err != nil {
return err
}
defer watcher.Close()
// serve the target dir with a file server
fs := http.FileServer(HTMLDir{http.Dir(config.TargetDir)})
fs := http.FileServer(HTMLFileSystem{http.Dir(config.TargetDir)})
http.Handle("/", http.StripPrefix("/", fs))
if config.LiveReload {
// handle client requests to listen to server-sent events
http.Handle("/_events/", makeServerEventsHandler(broker))
}
addr := fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort)
fmt.Printf("server listening at http://%s\n", addr)
return http.ListenAndServe(addr, nil)
}
func rebuild(config *config.Config) error {
// Return an http.HandlerFunc that establishes a server-sent event stream with clients,
// subscribes to site rebuild events received through the given event broker
// and forwards them to the client.
func makeServerEventsHandler(broker *EventBroker) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("Cache-Control", "no-cache")
res.Header().Set("Access-Control-Allow-Origin", "*")
site, err := site.Load(*config)
if err != nil {
return err
}
if err := site.Build(); err != nil {
return err
}
return nil
}
// Tweaks the http file system to construct a server that hides the .html suffix from requests.
// Based on https://stackoverflow.com/a/57281956/993769
type HTMLDir struct {
d http.Dir
}
func (d HTMLDir) Open(name string) (http.File, error) {
// Try name as supplied
f, err := d.d.Open(name)
if os.IsNotExist(err) {
// Not found, try with .html
if f, err := d.d.Open(name + ".html"); err == nil {
return f, nil
id, events := broker.subscribe()
for {
select {
case <-events:
// send an event to the connected client.
// data\n\n just means send an empty, unnamed event
// since we only need to support the single reload operation.
fmt.Fprint(res, "data\n\n")
res.(http.Flusher).Flush()
case <-req.Context().Done():
broker.unsubscribe(id)
return
}
}
}
return f, err
}
func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
// Sets up a watcher that will publish changes in the site source files
// to the returned event broker.
func setupWatcher(config *config.Config) (*fsnotify.Watcher, *EventBroker, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
return nil, nil, err
}
broker := newEventBroker()
go func() {
for {
select {
@ -91,7 +97,7 @@ func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
continue
}
fmt.Printf("\nFile %s changed, triggering rebuild.\n", event.Name)
fmt.Printf("\nFile %s changed, rebuilding site.\n", event.Name)
// since new nested directories could be triggering this change, and we need to watch those too
// and since re-watching files is a noop, I just re-add the entire src everytime there's a change
@ -100,10 +106,11 @@ func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
continue
}
if err := rebuild(config); err != nil {
if err := rebuildSite(config); err != nil {
fmt.Println("build error:", err)
continue
}
broker.publish("rebuild")
fmt.Println("done\nserver listening at", config.SiteUrl)
@ -118,7 +125,7 @@ func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
err = addAll(watcher, config)
return watcher, err
return watcher, broker, err
}
// Add the layouts and all source directories to the given watcher
@ -136,3 +143,99 @@ func addAll(watcher *fsnotify.Watcher, config *config.Config) error {
})
return err
}
func rebuildSite(config *config.Config) error {
site, err := site.Load(*config)
if err != nil {
return err
}
if err := site.Build(); err != nil {
return err
}
return nil
}
// Tweaks the http file system to construct a server that hides the .html suffix from requests.
// Based on https://stackoverflow.com/a/57281956/993769
type HTMLFileSystem struct {
d http.Dir
}
func (d HTMLFileSystem) Open(name string) (http.File, error) {
// Try name as supplied
f, err := d.d.Open(name)
if os.IsNotExist(err) {
// Not found, try with .html
if f, err := d.d.Open(name + ".html"); err == nil {
return f, nil
}
}
return f, err
}
// The event broker allows the file watcher to publish site rebuild events
// and register http clients to listen for them, in order to trigger browser refresh
// events after the the site has been rebuilt.
type EventBroker struct {
inEvents chan string
inSubscriptions chan Subscription
subscribers map[uint64]chan string
idgen atomic.Uint64
}
type Subscription struct {
id uint64
outEvents chan string
}
func newEventBroker() *EventBroker {
broker := EventBroker{
inEvents: make(chan string),
inSubscriptions: make(chan Subscription),
subscribers: map[uint64]chan string{},
}
go func() {
for {
select {
case msg := <-broker.inSubscriptions:
if msg.outEvents != nil {
// subscribe
broker.subscribers[msg.id] = msg.outEvents
} else {
// unsubscribe
close(broker.subscribers[msg.id])
delete(broker.subscribers, msg.id)
}
case msg := <-broker.inEvents:
// send the event to all the subscribers
for _, outEvents := range broker.subscribers {
outEvents <- msg
}
}
}
}()
return &broker
}
// Adds a subscription to this broker events, returning a subscriber id
// (useful for unsubscribing later) and a channel where events will be delivered.
func (broker *EventBroker) subscribe() (uint64, <-chan string) {
id := broker.idgen.Add(1)
outEvents := make(chan string)
broker.inSubscriptions <- Subscription{id, outEvents}
return id, outEvents
}
// Remove the subscriber with the given id from the broker,
// closing its associated channel.
func (broker *EventBroker) unsubscribe(id uint64) {
broker.inSubscriptions <- Subscription{id: id, outEvents: nil}
}
// Publish an event to all the broker subscribers.
func (broker *EventBroker) publish(event string) {
broker.inEvents <- event
}

View file

@ -87,7 +87,7 @@ func Load(rootDir string) (*Config, error) {
return config, nil
}
func LoadDevServer(rootDir string) (*Config, error) {
func LoadDev(rootDir string) (*Config, error) {
// TODO revisit is this Load vs LoadDevServer is the best way to handle both modes
// TODO some of the options need to be overridable: host, port, live reload at least

93
site/html.go Normal file
View file

@ -0,0 +1,93 @@
package site
import (
"bytes"
"io"
"golang.org/x/net/html"
)
// Find the first p tag in the given html document and return its text content.
func ExtractFirstParagraph(htmlReader io.Reader) string {
html, err := html.Parse(htmlReader)
if err != nil {
return ""
}
ptag := findFirstElement(html, "p")
if ptag == nil {
return ""
}
return getTextContent(ptag)
}
// Inject a <script> tag with the given JavaScript code into provided the HTML document
// and return the updated document as a new io.Reader
func InjectScript(htmlReader io.Reader, jsCode string) (io.Reader, error) {
doc, err := html.Parse(htmlReader)
if err != nil {
return nil, err
}
scriptNode := &html.Node{
Type: html.ElementNode,
Data: "script",
Attr: []html.Attribute{
{Key: "type", Val: "text/javascript"},
},
}
// insert the script code inside the script tag
scriptTextNode := &html.Node{
Type: html.TextNode,
Data: jsCode,
}
scriptNode.AppendChild(scriptTextNode)
head := findFirstElement(doc, "head")
if head == nil {
// If <head> element not found, create one and append it to the document
head = &html.Node{
Type: html.ElementNode,
Data: "head",
}
doc.InsertBefore(head, doc.FirstChild)
}
// Append the <script> element to the <head> element
head.AppendChild(scriptNode)
// Serialize the modified HTML document to a buffer
var buf bytes.Buffer
if err := html.Render(&buf, doc); err != nil {
return nil, err
}
// Return a reader for the modified HTML content
return &buf, nil
}
// Finds the first occurrence of the specified element in the HTML document
func findFirstElement(n *html.Node, tagName string) *html.Node {
if n.Type == html.ElementNode && n.Data == tagName {
return n
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if element := findFirstElement(c, tagName); element != nil {
return element
}
}
return nil
}
// Finds the <head> element in the HTML document
func getTextContent(node *html.Node) string {
var textContent string
if node.Type == html.TextNode {
textContent = node.Data
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
textContent += getTextContent(c)
}
return textContent
}

View file

@ -23,6 +23,7 @@ func (site *Site) loadMinifier() {
site.minifier.AddFunc(".xml", xml.Minify)
}
// if enabled by config, minify web files
func (site *Site) minify(extension string, contentReader io.Reader) io.Reader {
if !site.Config.Minify || !slices.Contains(SUPPORTED_MINIFIERS, extension) {

View file

@ -15,7 +15,6 @@ import (
"github.com/facundoolano/jorge/config"
"github.com/facundoolano/jorge/templates"
"golang.org/x/net/html"
"gopkg.in/yaml.v3"
)
@ -268,12 +267,10 @@ func (site *Site) buildFile(path string) error {
}
targetExt := filepath.Ext(targetPath)
// if live reload is enabled, inject the reload snippet to html files
if site.Config.LiveReload && targetExt == ".html" {
// TODO inject live reload snippet
contentReader, err = site.injectLiveReload(targetExt, contentReader)
if err != nil {
return err
}
// if enabled, minify web files
contentReader = site.minify(targetExt, contentReader)
// write the file contents over to target
@ -365,35 +362,28 @@ func getExcerpt(templ *templates.Template) string {
if err != nil {
return ""
}
html, err := html.Parse(bytes.NewReader(content))
if err != nil {
return ""
}
ptag := findFirstParagraph(html)
return getTextContent(ptag)
return ExtractFirstParagraph(bytes.NewReader(content))
}
func findFirstParagraph(node *html.Node) *html.Node {
if node.Type == html.ElementNode && node.Data == "p" {
return node
// if live reload is enabled, inject the reload snippet to html files
func (site *Site) injectLiveReload(extension string, contentReader io.Reader) (io.Reader, error) {
if !site.Config.LiveReload || extension != ".html" {
return contentReader, nil
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
if p := findFirstParagraph(c); p != nil {
return p
}
}
return nil
}
func getTextContent(node *html.Node) string {
var textContent string
if node.Type == html.TextNode {
textContent = node.Data
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
textContent += getTextContent(c)
}
return textContent
const JS_SNIPPET = `
const url = '%s/_events/'
const eventSource = new EventSource(url);
eventSource.onmessage = function () {
location.reload()
};
window.onbeforeunload = function() {
eventSource.close();
}
eventSource.onerror = function (event) {
console.error('An error occurred:', event)
};`
script := fmt.Sprintf(JS_SNIPPET, site.Config.SiteUrl)
return InjectScript(contentReader, script)
}