mirror of
https://github.com/facundoolano/jorge.git
synced 2024-11-16 07:47:40 +01:00
Add live reload to build command (#7)
This commit is contained in:
parent
6c8fd6aed2
commit
2d84e1f2cb
5 changed files with 258 additions and 71 deletions
|
@ -6,77 +6,83 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/facundoolano/jorge/config"
|
"github.com/facundoolano/jorge/config"
|
||||||
"github.com/facundoolano/jorge/site"
|
"github.com/facundoolano/jorge/site"
|
||||||
"github.com/fsnotify/fsnotify"
|
"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 {
|
func Serve(rootDir string) error {
|
||||||
config, err := config.LoadDevServer(rootDir)
|
config, err := config.LoadDev(rootDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rebuild(config); err != nil {
|
if err := rebuildSite(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// watch for changes in src and layouts, and trigger a rebuild
|
// watch for changes in src and layouts, and trigger a rebuild
|
||||||
watcher, err := setupWatcher(config)
|
watcher, broker, err := setupWatcher(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer watcher.Close()
|
defer watcher.Close()
|
||||||
|
|
||||||
// serve the target dir with a file server
|
// 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))
|
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)
|
addr := fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort)
|
||||||
fmt.Printf("server listening at http://%s\n", addr)
|
fmt.Printf("server listening at http://%s\n", addr)
|
||||||
return http.ListenAndServe(addr, nil)
|
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)
|
id, events := broker.subscribe()
|
||||||
if err != nil {
|
for {
|
||||||
return err
|
select {
|
||||||
}
|
case <-events:
|
||||||
|
// send an event to the connected client.
|
||||||
if err := site.Build(); err != nil {
|
// data\n\n just means send an empty, unnamed event
|
||||||
return err
|
// since we only need to support the single reload operation.
|
||||||
}
|
fmt.Fprint(res, "data\n\n")
|
||||||
|
res.(http.Flusher).Flush()
|
||||||
return nil
|
case <-req.Context().Done():
|
||||||
}
|
broker.unsubscribe(id)
|
||||||
|
return
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
watcher, err := fsnotify.NewWatcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broker := newEventBroker()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
@ -91,7 +97,7 @@ func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
|
||||||
continue
|
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
|
// 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
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rebuild(config); err != nil {
|
if err := rebuildSite(config); err != nil {
|
||||||
fmt.Println("build error:", err)
|
fmt.Println("build error:", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
broker.publish("rebuild")
|
||||||
|
|
||||||
fmt.Println("done\nserver listening at", config.SiteUrl)
|
fmt.Println("done\nserver listening at", config.SiteUrl)
|
||||||
|
|
||||||
|
@ -118,7 +125,7 @@ func setupWatcher(config *config.Config) (*fsnotify.Watcher, error) {
|
||||||
|
|
||||||
err = addAll(watcher, config)
|
err = addAll(watcher, config)
|
||||||
|
|
||||||
return watcher, err
|
return watcher, broker, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the layouts and all source directories to the given watcher
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -87,7 +87,7 @@ func Load(rootDir string) (*Config, error) {
|
||||||
return config, nil
|
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 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
|
// TODO some of the options need to be overridable: host, port, live reload at least
|
||||||
|
|
||||||
|
|
93
site/html.go
Normal file
93
site/html.go
Normal 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
|
||||||
|
}
|
|
@ -23,6 +23,7 @@ func (site *Site) loadMinifier() {
|
||||||
site.minifier.AddFunc(".xml", xml.Minify)
|
site.minifier.AddFunc(".xml", xml.Minify)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if enabled by config, minify web files
|
||||||
func (site *Site) minify(extension string, contentReader io.Reader) io.Reader {
|
func (site *Site) minify(extension string, contentReader io.Reader) io.Reader {
|
||||||
|
|
||||||
if !site.Config.Minify || !slices.Contains(SUPPORTED_MINIFIERS, extension) {
|
if !site.Config.Minify || !slices.Contains(SUPPORTED_MINIFIERS, extension) {
|
||||||
|
|
56
site/site.go
56
site/site.go
|
@ -15,7 +15,6 @@ import (
|
||||||
|
|
||||||
"github.com/facundoolano/jorge/config"
|
"github.com/facundoolano/jorge/config"
|
||||||
"github.com/facundoolano/jorge/templates"
|
"github.com/facundoolano/jorge/templates"
|
||||||
"golang.org/x/net/html"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -268,12 +267,10 @@ func (site *Site) buildFile(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
targetExt := filepath.Ext(targetPath)
|
targetExt := filepath.Ext(targetPath)
|
||||||
// if live reload is enabled, inject the reload snippet to html files
|
contentReader, err = site.injectLiveReload(targetExt, contentReader)
|
||||||
if site.Config.LiveReload && targetExt == ".html" {
|
if err != nil {
|
||||||
// TODO inject live reload snippet
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// if enabled, minify web files
|
|
||||||
contentReader = site.minify(targetExt, contentReader)
|
contentReader = site.minify(targetExt, contentReader)
|
||||||
|
|
||||||
// write the file contents over to target
|
// write the file contents over to target
|
||||||
|
@ -365,35 +362,28 @@ func getExcerpt(templ *templates.Template) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return ExtractFirstParagraph(bytes.NewReader(content))
|
||||||
html, err := html.Parse(bytes.NewReader(content))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
ptag := findFirstParagraph(html)
|
|
||||||
return getTextContent(ptag)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findFirstParagraph(node *html.Node) *html.Node {
|
// if live reload is enabled, inject the reload snippet to html files
|
||||||
if node.Type == html.ElementNode && node.Data == "p" {
|
func (site *Site) injectLiveReload(extension string, contentReader io.Reader) (io.Reader, error) {
|
||||||
return node
|
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 {
|
const JS_SNIPPET = `
|
||||||
var textContent string
|
const url = '%s/_events/'
|
||||||
if node.Type == html.TextNode {
|
const eventSource = new EventSource(url);
|
||||||
textContent = node.Data
|
|
||||||
}
|
eventSource.onmessage = function () {
|
||||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
location.reload()
|
||||||
textContent += getTextContent(c)
|
};
|
||||||
}
|
window.onbeforeunload = function() {
|
||||||
return textContent
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource.onerror = function (event) {
|
||||||
|
console.error('An error occurred:', event)
|
||||||
|
};`
|
||||||
|
script := fmt.Sprintf(JS_SNIPPET, site.Config.SiteUrl)
|
||||||
|
return InjectScript(contentReader, script)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue