mirror of
https://github.com/facundoolano/jorge.git
synced 2024-12-25 21:58:28 +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"
|
||||
"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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
52
site/site.go
52
site/site.go
|
@ -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 ""
|
||||
return ExtractFirstParagraph(bytes.NewReader(content))
|
||||
}
|
||||
|
||||
ptag := findFirstParagraph(html)
|
||||
return getTextContent(ptag)
|
||||
// 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
|
||||
}
|
||||
|
||||
func findFirstParagraph(node *html.Node) *html.Node {
|
||||
if node.Type == html.ElementNode && node.Data == "p" {
|
||||
return node
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
if p := findFirstParagraph(c); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
const JS_SNIPPET = `
|
||||
const url = '%s/_events/'
|
||||
const eventSource = new EventSource(url);
|
||||
|
||||
func getTextContent(node *html.Node) string {
|
||||
var textContent string
|
||||
if node.Type == html.TextNode {
|
||||
textContent = node.Data
|
||||
eventSource.onmessage = function () {
|
||||
location.reload()
|
||||
};
|
||||
window.onbeforeunload = function() {
|
||||
eventSource.close();
|
||||
}
|
||||
for c := node.FirstChild; c != nil; c = c.NextSibling {
|
||||
textContent += getTextContent(c)
|
||||
}
|
||||
return textContent
|
||||
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