SSG is a Static Site Generator that is only a Static Site Generator. No resumes here! Just a piece of code that generates static files from templates for websites, and can do it live while you develop said templates.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
ssgod/main.go

420 lines
9.0 KiB

package main
import (
"bytes"
_ "embed"
"flag"
"fmt"
"github.com/fsnotify/fsnotify"
"github.com/yuin/goldmark"
"io"
"io/fs"
"lcthw.dev/go/ssgod/config"
"log"
"os"
"path/filepath"
"strings"
"text/template"
"time"
)
//go:embed example/.ssgod.json
var DEFAULT_CONFIG string
type PageMetaData struct {
pages []string
}
func (meta *PageMetaData) AddPage(path string) {
path = RePrefixPath(path, "/")
path = filepath.ToSlash(path)
meta.pages = append(meta.pages, path)
}
func Fatal(err error, format string, v ...any) {
err_format := fmt.Sprintf("ERROR: %v; %s", err, format)
panic(fmt.Sprintf(err_format, v...))
}
func Fail(err error, format string, v ...any) error {
err_format := fmt.Sprintf("ERROR: %v; %s", err, format)
panic(fmt.Sprintf(err_format, v...))
return err
}
func RenderTemplate(out io.Writer, embed string, variables any) error {
layout_path := config.Settings.Layout
layout_main, err := os.ReadFile(layout_path)
if err != nil {
return Fail(err, "can't read your layout file: %s", layout_path)
}
tmpl := template.New(layout_path)
callbacks := template.FuncMap{
"embed": func() string {
tmpl, err = tmpl.Parse(embed)
if err != nil {
Fatal(err, "error in your template")
}
out := bytes.NewBuffer(make([]byte, 0, 100))
tmpl.Execute(out, variables)
return out.String()
},
}
tmpl.Funcs(callbacks)
tmpl, err = tmpl.Parse(string(layout_main))
if err != nil {
return Fail(err, "can't parse %s", layout_path)
}
err = tmpl.Execute(out, variables)
return err
}
func RenderMetaFiles(meta *PageMetaData) {
fmt.Println("PAGES: ", meta.pages)
for _, path := range config.Settings.MetaFiles {
fmt.Println(">>> META >>>>", path)
ext := filepath.Ext(path)
target_path := RePrefixPath(path, config.Settings.Target)
if ext == ".md" {
RenderMarkdown(path, target_path,
map[string]any{"Pages": meta.pages})
} else if ext == ".html" {
RenderHTML(path, target_path,
map[string]any{"Pages": meta.pages})
}
}
}
func RenderMarkdown(path string, target_path string, vars map[string]any) error {
log.Printf("MARKDOWN: %s -> %s", path, target_path)
out, err := os.Create(target_path)
defer out.Close()
if err != nil {
return Fail(err, "writing file %s", target_path)
}
input_data, err := os.ReadFile(path)
var md_out bytes.Buffer
err = goldmark.Convert(input_data, &md_out)
if err != nil {
return Fail(err, "failed converting markdown %s", path)
}
err = RenderTemplate(out, md_out.String(), vars)
if err != nil {
return Fail(err, "failed to render template %s->%s", path, target_path)
}
return err
}
func RenderHTML(source_path string, target_path string, vars map[string]any) error {
log.Printf("RENDER: %s -> %s", source_path, target_path)
out, err := os.Create(target_path)
defer out.Close()
content, err := os.ReadFile(source_path)
if err != nil {
return Fail(err, "cannot open input %s", source_path)
}
err = RenderTemplate(out, string(content), vars)
if err != nil {
return Fail(err, "writing file %s", target_path)
}
return err
}
func MkdirPath(target_path string) error {
target_dir := filepath.Dir(target_path)
_, err := os.Stat(target_dir)
if os.IsNotExist(err) {
log.Println("MAKING: ", target_dir)
err = os.MkdirAll(target_dir, 0750)
if err != nil {
return Fail(err, "making path to %s", target_dir)
}
}
return nil
}
func UnfuckedPathSplit(path string) []string {
path = filepath.ToSlash(path)
// WARN: have to use strings.Split because fsnotify uses /, even on windows
return strings.Split(path, "/")[1:]
}
func SplitPathExt(path string) (string, string, bool) {
split_path := UnfuckedPathSplit(path)
source_name := strings.Join(split_path, "/") // Render wants / even on windows
ext := filepath.Ext(source_name)
source_name, found := strings.CutSuffix(source_name, ext)
return source_name, ext, found
}
func RePrefixPath(path string, new_prefix string) string {
split_path := UnfuckedPathSplit(path)
prefixed_path := append([]string{new_prefix}, split_path...)
res := filepath.Join(prefixed_path...)
return res
}
func SamePath(a string, b string) bool {
return filepath.ToSlash(a) == filepath.ToSlash(b)
}
func ProcessDirEntry(path string, d fs.DirEntry, meta *PageMetaData) error {
settings := config.Settings
var err error = nil
if !d.IsDir() && !SamePath(path, config.Settings.Layout) {
source_name, ext, found := SplitPathExt(path)
if found && path != settings.Layout {
target_path := RePrefixPath(path, settings.Target)
err = MkdirPath(target_path)
if err != nil {
return Fail(err, "making target path: %s", target_path)
}
// generate a data-testid for all pages based on template name
page_id := strings.ReplaceAll(source_name, "/", "-") + "-page"
if ext == ".html" {
err = RenderHTML(path, target_path,
map[string]any{"PageId": page_id})
if err != nil {
return Fail(err, "failed to render %s", path)
}
meta.AddPage(target_path)
} else if ext == ".md" {
// need to strip the .md and replace with .html
html_name, _ := strings.CutSuffix(target_path, ext)
html_name = fmt.Sprintf("%s.html", html_name)
RenderMarkdown(path, html_name,
map[string]any{"PageId": page_id})
if err != nil {
return Fail(err, "failed to render markdown %s", path)
}
meta.AddPage(html_name)
}
}
}
return nil
}
func SyncStaticDir() {
target := config.Settings.Target
sync_dir := config.Settings.SyncDir
if sync_dir == "" {
return
}
log.Printf("removing target directory: %s", target)
err := os.RemoveAll(target)
if err != nil {
Fatal(err, "can't remove target directory: %s", target)
}
err = os.MkdirAll(target, 0750)
if err != nil {
Fatal(err, "can't recreate target directory: %s", target)
}
source := os.DirFS(sync_dir)
log.Printf("SYNC %s -> %s", sync_dir, target)
err = os.CopyFS(target, source)
if err != nil {
Fatal(err, "can't sync %s to target directory: %s", sync_dir, target)
}
}
func RenderPages() {
meta := new(PageMetaData)
err := filepath.WalkDir(config.Settings.Views,
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
err = ProcessDirEntry(path, d, meta)
if err != nil {
return Fail(err, "failed to process %s", path)
}
return err
})
if err != nil {
Fatal(err, "can't walk content")
}
RenderMetaFiles(meta)
}
func WatchMatches(name string) bool {
is_static := strings.Index(name, config.Settings.SyncDir) == 0
return is_static || filepath.Ext(name) == ".html" || filepath.Ext(name) == ".md"
}
func AddWatchDir(watcher *fsnotify.Watcher, name string) error {
return filepath.WalkDir(name,
func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("WATCH ERROR! walking=%s path=%s: err=%v", name, path, err)
return err
}
if d.IsDir() {
log.Println("WATCHING: ", path)
err = watcher.Add(path)
if err != nil {
log.Printf("failed to watch %s", path)
return err
}
}
return nil
})
}
func WatchDir() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
Fatal(err, "can't create new watcher")
}
defer watcher.Close()
wait_time, err := time.ParseDuration(config.Settings.WatchDelay)
if err != nil {
Fatal(err,
"can't parse watch_delay setting: %s",
config.Settings.WatchDelay)
}
doit := time.NewTimer(wait_time)
doit.Stop()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Has(fsnotify.Create) {
log.Println("---> CREATE IS:", event.Name)
AddWatchDir(watcher, event.Name)
} else {
log.Println("event: ", event)
}
if WatchMatches(event.Name) {
log.Println("modified file: ", event.Name)
doit.Reset(wait_time)
}
case <-doit.C:
SyncStaticDir()
RenderPages()
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error: ", err)
}
}
}()
err = AddWatchDir(watcher, config.Settings.Views)
if err != nil {
Fatal(err, "failed to watch %s", config.Settings.Views)
}
err = AddWatchDir(watcher, filepath.Dir(config.Settings.Layout))
if err != nil {
Fatal(err, "failed to watch %s", filepath.Dir(config.Settings.Layout))
}
if config.Settings.SyncDir != "" {
err = AddWatchDir(watcher, config.Settings.SyncDir)
if err != nil {
Fatal(err, "failed to watch %s", config.Settings.SyncDir)
}
}
<-make(chan struct{})
}
func InitConfig(config_file string) {
_, err := os.Stat(config_file)
if os.IsNotExist(err) {
out, err := os.Create(config_file)
if err != nil {
Fatal(err, "error opening %s", config_file)
}
defer out.Close()
out.WriteString(DEFAULT_CONFIG)
fmt.Println("new config written to:", config_file)
} else {
Fatal(err, "there's already a %s file here", config_file)
}
}
func main() {
var config_file string
flag.StringVar(&config_file, "config", ".ssgod.json", ".json config file to use")
flag.Parse()
command := flag.Arg(0)
switch command {
case "watch":
config.Load(config_file)
SyncStaticDir()
RenderPages()
WatchDir()
case "init":
InitConfig(config_file)
default:
config.Load(config_file)
SyncStaticDir()
RenderPages()
}
}