package main
import (
"bytes"
"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"
_ "embed"
)
//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 ( )
}
}