├── .gitignore ├── atom ├── feed.go └── uuid.go ├── commands ├── build.go ├── init.go ├── new.go └── serve.go ├── config └── config.go ├── contrib └── style.go ├── default.nix ├── fileutil ├── copy.go └── rmall.go ├── flake.lock ├── flake.nix ├── formats ├── anything.go ├── markdown │ └── markdown.go └── yaml │ └── yaml.go ├── go.mod ├── go.sum ├── license ├── main.go ├── makefile ├── readme ├── template └── template.go └── types └── types.go /.gitignore: -------------------------------------------------------------------------------- 1 | vite 2 | .env 3 | .direnv 4 | result 5 | -------------------------------------------------------------------------------- /atom/feed.go: -------------------------------------------------------------------------------- 1 | package atom 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "net/url" 7 | "path/filepath" 8 | "time" 9 | 10 | "tangled.sh/icyphox.sh/vite/config" 11 | "tangled.sh/icyphox.sh/vite/types" 12 | ) 13 | 14 | type AtomLink struct { 15 | XMLName xml.Name `xml:"link"` 16 | Href string `xml:"href,attr"` 17 | Rel string `xml:"rel,attr,omitempty"` 18 | } 19 | 20 | type AtomSummary struct { 21 | XMLName xml.Name `xml:"summary"` 22 | Content string `xml:",chardata"` 23 | Type string `xml:"type,attr"` 24 | } 25 | 26 | type AtomAuthor struct { 27 | XMLName xml.Name `xml:"author"` 28 | Name string `xml:"name"` 29 | Email string `xml:"email"` 30 | } 31 | 32 | type AtomEntry struct { 33 | XMLName xml.Name `xml:"entry"` 34 | Title string `xml:"title"` 35 | Updated string `xml:"updated"` 36 | ID string `xml:"id"` 37 | Link *AtomLink 38 | Summary *AtomSummary 39 | } 40 | 41 | type AtomFeed struct { 42 | XMLName xml.Name `xml:"feed"` 43 | Xmlns string `xml:"xmlns,attr"` 44 | Title string `xml:"title"` 45 | Subtitle string `xml:"subtitle"` 46 | ID string `xml:"id"` 47 | Updated string `xml:"updated"` 48 | Link *AtomLink 49 | Author *AtomAuthor `xml:"author"` 50 | Entries []AtomEntry 51 | } 52 | 53 | // Creates a new Atom feed. 54 | func NewAtomFeed(srcDir string, posts []types.Post) ([]byte, error) { 55 | entries := []AtomEntry{} 56 | 57 | for _, p := range posts { 58 | dateStr := p.Meta["date"].(string) 59 | date, err := time.Parse("2006-01-02", dateStr) 60 | if err != nil { 61 | return nil, err 62 | } 63 | rfc3339 := date.Format(time.RFC3339) 64 | 65 | var summaryContent string 66 | if subtitle, ok := p.Meta["subtitle"]; ok { 67 | summaryContent = fmt.Sprintf("

%s

\n%s", 68 | subtitle.(string), 69 | string(p.Body)) 70 | } else { 71 | summaryContent = string(p.Body) 72 | } 73 | 74 | entry := AtomEntry{ 75 | Title: p.Meta["title"].(string), 76 | Updated: rfc3339, 77 | // tag:icyphox.sh,2019-10-23:blog/some-post/ 78 | ID: fmt.Sprintf( 79 | "tag:%s,%s:%s", 80 | config.Config.URL[8:], // strip https:// 81 | dateStr, 82 | filepath.Join(srcDir, p.Meta["slug"].(string)), 83 | ), 84 | Link: newAtomLink(config.Config.URL, srcDir, p.Meta["slug"].(string)), 85 | Summary: &AtomSummary{ 86 | Content: summaryContent, 87 | Type: "html", 88 | }, 89 | } 90 | entries = append(entries, entry) 91 | } 92 | 93 | // 2021-07-14T00:00:00Z 94 | now := time.Now().Format(time.RFC3339) 95 | feed := &AtomFeed{ 96 | Xmlns: "http://www.w3.org/2005/Atom", 97 | Title: config.Config.Title, 98 | ID: config.Config.URL, 99 | Subtitle: config.Config.Desc, 100 | Link: &AtomLink{Href: config.Config.URL}, 101 | Author: &AtomAuthor{ 102 | Name: config.Config.Author.Name, 103 | Email: config.Config.Author.Email, 104 | }, 105 | Updated: now, 106 | Entries: entries, 107 | } 108 | 109 | feedXML, err := xml.MarshalIndent(feed, " ", " ") 110 | if err != nil { 111 | return nil, err 112 | } 113 | // Add the header. 114 | return []byte(xml.Header + string(feedXML)), nil 115 | } 116 | 117 | // Creates a new Atom link. 118 | // 119 | // Example: 120 | // 121 | // newAtomLink("https://example.com", "blog", "some-post") 122 | // // → 123 | func newAtomLink(base string, subdomain string, slug string) *AtomLink { 124 | baseURL, err := url.Parse(base) 125 | if err != nil { 126 | return nil 127 | } 128 | 129 | baseURL.Host = subdomain + "." + baseURL.Host 130 | baseURL.Path = slug 131 | 132 | return &AtomLink{Href: baseURL.String()} 133 | } 134 | -------------------------------------------------------------------------------- /atom/uuid.go: -------------------------------------------------------------------------------- 1 | package atom 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | type UUID [16]byte 9 | 10 | // Create a new uuid v4 11 | func NewUUID() *UUID { 12 | u := &UUID{} 13 | _, err := rand.Read(u[:16]) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | u[8] = (u[8] | 0x80) & 0xBf 19 | u[6] = (u[6] | 0x40) & 0x4f 20 | return u 21 | } 22 | 23 | func (u *UUID) String() string { 24 | return fmt.Sprintf("%x-%x-%x-%x-%x", u[:4], u[4:6], u[6:8], u[8:10], u[10:]) 25 | } 26 | -------------------------------------------------------------------------------- /commands/build.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | "time" 11 | 12 | "tangled.sh/icyphox.sh/vite/atom" 13 | "tangled.sh/icyphox.sh/vite/config" 14 | util "tangled.sh/icyphox.sh/vite/fileutil" 15 | "tangled.sh/icyphox.sh/vite/formats" 16 | "tangled.sh/icyphox.sh/vite/formats/markdown" 17 | "tangled.sh/icyphox.sh/vite/formats/yaml" 18 | "tangled.sh/icyphox.sh/vite/types" 19 | ) 20 | 21 | type Dir struct { 22 | Name string 23 | HasIndex bool 24 | Files []types.File 25 | } 26 | 27 | type Pages struct { 28 | Dirs []Dir 29 | Files []types.File 30 | 31 | // A map of directories to their respective posts. 32 | AllPosts map[string][]types.Post 33 | } 34 | 35 | func NewPages() (*Pages, error) { 36 | pages := &Pages{} 37 | 38 | entries, err := os.ReadDir(types.PagesDir) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | pages.AllPosts = make(map[string][]types.Post) 44 | 45 | for _, entry := range entries { 46 | if entry.IsDir() { 47 | thingsDir := filepath.Join(types.PagesDir, entry.Name()) 48 | dir := Dir{Name: entry.Name()} 49 | things, err := os.ReadDir(thingsDir) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | for _, thing := range things { 55 | if thing.Name() == "_index.md" { 56 | dir.HasIndex = true 57 | continue 58 | } 59 | switch filepath.Ext(thing.Name()) { 60 | case ".md": 61 | path := filepath.Join(thingsDir, thing.Name()) 62 | dir.Files = append(dir.Files, &markdown.Markdown{Path: path}) 63 | case ".yaml": 64 | path := filepath.Join(thingsDir, thing.Name()) 65 | dir.Files = append(dir.Files, &yaml.YAML{Path: path}) 66 | default: 67 | fmt.Printf("warn: unrecognized filetype for file: %s\n", thing.Name()) 68 | } 69 | } 70 | 71 | pages.Dirs = append(pages.Dirs, dir) 72 | } else { 73 | path := filepath.Join(types.PagesDir, entry.Name()) 74 | switch filepath.Ext(entry.Name()) { 75 | case ".md": 76 | pages.Files = append(pages.Files, &markdown.Markdown{Path: path}) 77 | case ".yaml": 78 | pages.Files = append(pages.Files, &yaml.YAML{Path: path}) 79 | default: 80 | pages.Files = append(pages.Files, formats.Anything{Path: path}) 81 | } 82 | } 83 | } 84 | 85 | return pages, nil 86 | } 87 | 88 | // Build is the core builder function. Converts markdown/yaml 89 | // to html, copies over non-.md/.yaml files, etc. 90 | func Build(drafts bool) error { 91 | startTime := time.Now() 92 | 93 | if err := preBuild(); err != nil { 94 | return err 95 | } 96 | fmt.Println("vite: building") 97 | 98 | pages, err := NewPages() 99 | if err != nil { 100 | return fmt.Errorf("error: reading 'pages/' %w", err) 101 | } 102 | 103 | if err := util.Clean(types.BuildDir); err != nil { 104 | return err 105 | } 106 | 107 | if err := pages.ProcessDirectories(drafts); err != nil { 108 | return err 109 | } 110 | 111 | if err := pages.ProcessFiles(drafts); err != nil { 112 | return err 113 | } 114 | 115 | buildStatic := filepath.Join(types.BuildDir, types.StaticDir) 116 | if err := os.MkdirAll(buildStatic, 0755); err != nil { 117 | return err 118 | } 119 | if err := util.CopyDir(types.StaticDir, buildStatic); err != nil { 120 | return err 121 | } 122 | 123 | buildTime := time.Since(startTime) 124 | fmt.Printf("vite: completed in %v\n", buildTime) 125 | 126 | return nil 127 | } 128 | 129 | // ProcessFiles handles root level files under 'pages', 130 | // for example: 'pages/_index.md' or 'pages/about.md'. 131 | func (p *Pages) ProcessFiles(drafts bool) error { 132 | for _, f := range p.Files { 133 | var htmlDir string 134 | if f.Basename() == "_index.md" { 135 | htmlDir = types.BuildDir 136 | } else { 137 | htmlDir = filepath.Join(types.BuildDir, strings.TrimSuffix(f.Basename(), f.Ext())) 138 | } 139 | 140 | destFile := filepath.Join(htmlDir, "index.html") 141 | if f.Ext() == "" { 142 | destFile = filepath.Join(types.BuildDir, f.Basename()) 143 | } else { 144 | if err := os.MkdirAll(htmlDir, 0755); err != nil { 145 | return err 146 | } 147 | } 148 | if err := f.Render(destFile, p.AllPosts, drafts); err != nil { 149 | return fmt.Errorf("error: failed to render %s: %w", destFile, err) 150 | } 151 | } 152 | return nil 153 | } 154 | 155 | // ProcessDirectories handles directories of posts under 'pages', 156 | // for example: 'pages/photos/foo.md' or 'pages/blog/bar.md'. 157 | func (p *Pages) ProcessDirectories(drafts bool) error { 158 | for _, dir := range p.Dirs { 159 | dstDir := filepath.Join(types.BuildDir, dir.Name) 160 | if err := os.MkdirAll(dstDir, 0755); err != nil { 161 | return fmt.Errorf("error: failed to create directory: %s: %w", dstDir, err) 162 | } 163 | 164 | posts := []types.Post{} 165 | 166 | for _, file := range dir.Files { 167 | post := types.Post{} 168 | // foo-bar.md -> foo-bar 169 | slug := strings.TrimSuffix(file.Basename(), file.Ext()) 170 | dstFile := filepath.Join(dstDir, slug, "index.html") 171 | 172 | // ex: build/blog/foo-bar/ 173 | if err := os.MkdirAll(filepath.Join(dstDir, slug), 0755); err != nil { 174 | return fmt.Errorf("error: failed to create directory: %s: %w", dstDir, err) 175 | } 176 | 177 | if err := file.Render(dstFile, nil, drafts); err != nil { 178 | return fmt.Errorf("error: failed to render %s: %w", dstFile, err) 179 | } 180 | 181 | post.Meta = file.Frontmatter() 182 | post.Body = file.Body() 183 | isDraft := post.Meta["draft"] == "true" 184 | if !isDraft || (isDraft && drafts) { 185 | posts = append(posts, post) 186 | } 187 | 188 | // Copy the post to the root if it's marked as such. 189 | // ex: build/blog/foo-bar -> build/foo-bar 190 | atroot, ok := post.Meta["atroot"] 191 | if ok && atroot.(bool) { 192 | os.Mkdir(filepath.Join(types.BuildDir, slug), 0755) 193 | df := filepath.Join(types.BuildDir, slug+".html") 194 | util.CopyFile(filepath.Join(dstDir, slug, "index.html"), df) 195 | } 196 | } 197 | 198 | sort.Slice(posts, func(i, j int) bool { 199 | dateStr1 := posts[j].Meta["date"].(string) 200 | dateStr2 := posts[i].Meta["date"].(string) 201 | date1, _ := time.Parse("2006-01-02", dateStr1) 202 | date2, _ := time.Parse("2006-01-02", dateStr2) 203 | return date1.Before(date2) 204 | }) 205 | 206 | if dir.HasIndex { 207 | indexMd := filepath.Join(types.PagesDir, dir.Name, "_index.md") 208 | index := markdown.Markdown{Path: indexMd} 209 | dstFile := filepath.Join(dstDir, "index.html") 210 | if err := index.Render(dstFile, posts, false); err != nil { 211 | return fmt.Errorf("error: failed to render index %s: %w", dstFile, err) 212 | } 213 | } 214 | 215 | xml, err := atom.NewAtomFeed(dir.Name, posts) 216 | if err != nil { 217 | return fmt.Errorf("error: failed to create atom feed for: %s: %w", dir.Name, err) 218 | } 219 | feedFile := filepath.Join(dstDir, "feed.xml") 220 | os.WriteFile(feedFile, xml, 0755) 221 | 222 | p.AllPosts[dir.Name] = posts 223 | } 224 | 225 | return nil 226 | } 227 | 228 | func runCmd(cmd string, args ...string) error { 229 | parts := strings.Fields(cmd) 230 | if len(parts) == 0 { 231 | return fmt.Errorf("error: is there an empty command?") 232 | } 233 | 234 | execCmd := exec.Command(parts[0], parts[1:]...) 235 | 236 | output, err := execCmd.CombinedOutput() 237 | if err != nil { 238 | return fmt.Errorf("error: command %q failed with %v: %s", cmd, err, output) 239 | } 240 | return nil 241 | } 242 | 243 | func postBuild() error { 244 | for _, cmd := range config.Config.PostBuild { 245 | fmt.Println("vite: running post-build command:", cmd) 246 | if err := runCmd(cmd); err != nil { 247 | return err 248 | } 249 | } 250 | return nil 251 | } 252 | 253 | func preBuild() error { 254 | for _, cmd := range config.Config.PreBuild { 255 | fmt.Println("vite: running pre-build command:", cmd) 256 | if err := runCmd(cmd); err != nil { 257 | return err 258 | } 259 | } 260 | return nil 261 | } 262 | -------------------------------------------------------------------------------- /commands/init.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func Init(path string) error { 10 | paths := []string{"build", "pages", "static", "templates"} 11 | var dirPath string 12 | 13 | for _, p := range paths { 14 | dirPath = filepath.Join(path, p) 15 | err := os.MkdirAll(dirPath, 0755) 16 | if err != nil { 17 | return err 18 | } 19 | } 20 | fp, _ := filepath.Abs(path) 21 | fmt.Printf("vite: created project at %s\n", fp) 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /commands/new.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func New(path string) error { 13 | _, file := filepath.Split(path) 14 | url := strings.TrimSuffix(file, filepath.Ext(file)) 15 | 16 | content := fmt.Sprintf(`--- 17 | atroot: true 18 | template: 19 | slug: %s 20 | title: 21 | subtitle: 22 | date: %s 23 | draft: true 24 | ---`, url, time.Now().Format("2006-01-02")) 25 | 26 | if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { 27 | _, err := os.Create(path) 28 | if err != nil { 29 | return err 30 | } 31 | os.WriteFile(path, []byte(content), 0755) 32 | fmt.Printf("vite: created new post at %s\n", path) 33 | return nil 34 | } 35 | 36 | fmt.Printf("error: %s already exists\n", path) 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /commands/serve.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func Serve(addr string) error { 9 | fs := http.FileServer(http.Dir("./build")) 10 | mux := http.NewServeMux() 11 | mux.Handle("/", fs) 12 | fmt.Printf("vite: serving on %s\n", addr) 13 | if err := http.ListenAndServe(addr, mux); err != nil { 14 | return err 15 | } 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "gopkg.in/yaml.v3" 8 | ) 9 | 10 | var Config ConfigYaml 11 | 12 | func init() { 13 | err := Config.parseConfig() 14 | if err != nil { 15 | fmt.Fprintf(os.Stderr, "error: config: %+v\n", err) 16 | os.Exit(1) 17 | } 18 | } 19 | 20 | type ConfigYaml struct { 21 | Title string `yaml:"title"` 22 | Desc string `yaml:"description"` 23 | DefaultTemplate string `yaml:"-"` 24 | Author struct { 25 | Name string `yaml:"name"` 26 | Email string `yaml:"email"` 27 | } `yaml:"author"` 28 | URL string `yaml:"url"` 29 | PreBuild []string `yaml:"preBuild"` 30 | PostBuild []string `yaml:"postBuild"` 31 | } 32 | 33 | // For backward compat with `default-template` 34 | func (c *ConfigYaml) UnmarshalYAML(value *yaml.Node) error { 35 | type Alias ConfigYaml // Create an alias to avoid recursion 36 | 37 | var aux Alias 38 | 39 | if err := value.Decode(&aux); err != nil { 40 | return err 41 | } 42 | 43 | // Handle the DefaultTemplate field 44 | var temp struct { 45 | DefaultTemplate1 string `yaml:"default-template"` 46 | DefaultTemplate2 string `yaml:"defaultTemplate"` 47 | } 48 | if err := value.Decode(&temp); err != nil { 49 | return err 50 | } 51 | 52 | if temp.DefaultTemplate1 != "" { 53 | aux.DefaultTemplate = temp.DefaultTemplate1 54 | } else { 55 | aux.DefaultTemplate = temp.DefaultTemplate2 56 | } 57 | 58 | *c = ConfigYaml(aux) // Assign the unmarshalled values back to the original struct 59 | 60 | return nil 61 | } 62 | 63 | func (c *ConfigYaml) parseConfig() error { 64 | cf, err := os.ReadFile("config.yaml") 65 | if err != nil { 66 | return err 67 | } 68 | if err = yaml.Unmarshal(cf, c); err != nil { 69 | return err 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /contrib/style.go: -------------------------------------------------------------------------------- 1 | // style.go: generate chroma css 2 | // go run contrib/style.go > syntax.css 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/alecthomas/chroma" 9 | "github.com/alecthomas/chroma/formatters/html" 10 | "github.com/alecthomas/chroma/styles" 11 | ) 12 | 13 | var syntax = chroma.MustNewStyle("syntax", chroma.StyleEntries{ 14 | chroma.CommentMultiline: "italic #999988", 15 | chroma.CommentPreproc: "bold #999999", 16 | chroma.CommentSingle: "italic #999988", 17 | chroma.CommentSpecial: "bold italic #999999", 18 | chroma.Comment: "italic #999988", 19 | chroma.Error: "bg:#e3d2d2 #a61717", 20 | chroma.GenericDeleted: "bg:#ffdddd #000000", 21 | chroma.GenericEmph: "italic #000000", 22 | chroma.GenericError: "#aa0000", 23 | chroma.GenericHeading: "#999999", 24 | chroma.GenericInserted: "bg:#ddffdd #000000", 25 | chroma.GenericOutput: "#888888", 26 | chroma.GenericPrompt: "#555555", 27 | chroma.GenericStrong: "bold", 28 | chroma.GenericSubheading: "#aaaaaa", 29 | chroma.GenericTraceback: "#aa0000", 30 | chroma.GenericUnderline: "underline", 31 | chroma.KeywordType: "bold #222222", 32 | chroma.Keyword: "bold #000000", 33 | chroma.LiteralNumber: "#009999", 34 | chroma.LiteralStringRegex: "#009926", 35 | chroma.LiteralStringSymbol: "#990073", 36 | chroma.LiteralString: "#509c93", 37 | chroma.NameAttribute: "#008080", 38 | chroma.NameBuiltinPseudo: "#999999", 39 | chroma.NameBuiltin: "#509c93", 40 | chroma.NameClass: "bold #666666", 41 | chroma.NameConstant: "#008080", 42 | chroma.NameDecorator: "bold #3c5d5d", 43 | chroma.NameEntity: "#509c93", 44 | chroma.NameException: "bold #444444", 45 | chroma.NameFunction: "bold #444444", 46 | chroma.NameLabel: "bold #444444", 47 | chroma.NameNamespace: "#555555", 48 | chroma.NameTag: "#000080", 49 | chroma.NameVariableClass: "#008080", 50 | chroma.NameVariableGlobal: "#008080", 51 | chroma.NameVariableInstance: "#008080", 52 | chroma.NameVariable: "#008080", 53 | chroma.Operator: "bold #000000", 54 | chroma.TextWhitespace: "#bbbbbb", 55 | chroma.Background: " bg:#ffffff", 56 | }) 57 | 58 | func main() { 59 | formatter := html.New(html.WithClasses(true)) 60 | formatter.WriteCSS(os.Stdout, styles.Get("catppuccin-latte")) 61 | } 62 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? ( 2 | let 3 | inherit (builtins) fetchTree fromJSON readFile; 4 | inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix; 5 | in 6 | import (fetchTree nixpkgs.locked) { 7 | overlays = [ 8 | (import "${fetchTree gomod2nix.locked}/overlay.nix") 9 | ]; 10 | } 11 | ) 12 | }: 13 | 14 | pkgs.buildGoApplication { 15 | pname = "vite"; 16 | version = "0.1"; 17 | pwd = ./.; 18 | src = ./.; 19 | modules = ./gomod2nix.toml; 20 | } 21 | -------------------------------------------------------------------------------- /fileutil/copy.go: -------------------------------------------------------------------------------- 1 | package fileutil 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // CopyFile copies a file from src to dst. 11 | func CopyFile(src, dst string) error { 12 | in, err := os.Open(src) 13 | if err != nil { 14 | return err 15 | } 16 | defer in.Close() 17 | 18 | out, err := os.Create(dst) 19 | if err != nil { 20 | return err 21 | } 22 | defer out.Close() 23 | 24 | _, err = io.Copy(out, in) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | // Copy modes. 30 | f, err := os.Stat(src) 31 | if err == nil { 32 | err = os.Chmod(dst, f.Mode()) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | 38 | return out.Close() 39 | } 40 | 41 | // CopyDir copies an entire directory tree from 42 | // src to dst. 43 | func CopyDir(src, dst string) error { 44 | fi, err := os.Stat(src) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if !fi.IsDir() { 50 | return fmt.Errorf("error: %q is not a directory", fi) 51 | } 52 | 53 | if err = os.MkdirAll(dst, 0755); err != nil { 54 | return err 55 | } 56 | 57 | items, _ := os.ReadDir(src) 58 | for _, item := range items { 59 | srcFilename := filepath.Join(src, item.Name()) 60 | dstFilename := filepath.Join(dst, item.Name()) 61 | if item.IsDir() { 62 | if err := CopyDir(srcFilename, dstFilename); err != nil { 63 | return err 64 | } 65 | } else { 66 | if err := CopyFile(srcFilename, dstFilename); err != nil { 67 | return err 68 | } 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /fileutil/rmall.go: -------------------------------------------------------------------------------- 1 | package fileutil 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // Cleans a given directory, removing all files and subdirs. 9 | func Clean(dir string) error { 10 | files, err := filepath.Glob(filepath.Join(dir, "*")) 11 | if err != nil { 12 | return err 13 | } 14 | 15 | for _, file := range files { 16 | err = os.RemoveAll(file) 17 | if err != nil { 18 | return err 19 | } 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1716807034, 6 | "narHash": "sha256-AQwfMBtC8tiDZHEgLssOU4qUiRhC2nO+YdxAvZXeW7o=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "e751fce87c5d9a8c9375b59d5b508a34a76b5623", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "repo": "nixpkgs", 15 | "type": "github" 16 | } 17 | }, 18 | "root": { 19 | "inputs": { 20 | "nixpkgs": "nixpkgs" 21 | } 22 | } 23 | }, 24 | "root": "root", 25 | "version": 7 26 | } 27 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "a fast and minimal static site generator"; 3 | 4 | inputs.nixpkgs.url = "github:nixos/nixpkgs"; 5 | 6 | outputs = 7 | { self 8 | , nixpkgs 9 | , 10 | }: 11 | let 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; 13 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 14 | nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); 15 | in 16 | { 17 | overlay = final: prev: { 18 | vite = self.packages.${prev.system}.vite; 19 | }; 20 | nixosModule = import ./module.nix; 21 | packages = forAllSystems (system: 22 | let 23 | pkgs = nixpkgsFor.${system}; 24 | in 25 | { 26 | vite = pkgs.buildGoModule { 27 | name = "vite"; 28 | rev = "master"; 29 | src = ./.; 30 | 31 | vendorHash = "sha256-jZO2ZX5Ik3TxBWMkq4TkA3TZvzGTQsuKRNKZFQt3gac="; 32 | }; 33 | }); 34 | 35 | defaultPackage = forAllSystems (system: self.packages.${system}.vite); 36 | devShells = forAllSystems (system: 37 | let 38 | pkgs = nixpkgsFor.${system}; 39 | in 40 | { 41 | default = pkgs.mkShell { 42 | nativeBuildInputs = with pkgs; [ 43 | go 44 | ]; 45 | }; 46 | }); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /formats/anything.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | util "tangled.sh/icyphox.sh/vite/fileutil" 7 | ) 8 | 9 | // Anything is a stub format for unrecognized files 10 | type Anything struct{ Path string } 11 | 12 | func (Anything) Ext() string { return "" } 13 | func (Anything) Frontmatter() map[string]any { return nil } 14 | func (Anything) Body() string { return "" } 15 | func (a Anything) Basename() string { return filepath.Base(a.Path) } 16 | 17 | func (a Anything) Render(dest string, data interface{}, drafts bool) error { 18 | return util.CopyFile(a.Path, dest) 19 | } 20 | -------------------------------------------------------------------------------- /formats/markdown/markdown.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | gotmpl "text/template" 9 | "time" 10 | 11 | "github.com/adrg/frontmatter" 12 | "tangled.sh/icyphox.sh/vite/config" 13 | "tangled.sh/icyphox.sh/vite/template" 14 | "tangled.sh/icyphox.sh/vite/types" 15 | 16 | bf "git.icyphox.sh/grayfriday" 17 | ) 18 | 19 | var ( 20 | bfFlags = bf.UseXHTML | bf.Smartypants | bf.SmartypantsFractions | 21 | bf.SmartypantsDashes | bf.NofollowLinks | bf.FootnoteReturnLinks 22 | bfExts = bf.NoIntraEmphasis | bf.Tables | bf.FencedCode | bf.Autolink | 23 | bf.Strikethrough | bf.SpaceHeadings | bf.BackslashLineBreak | 24 | bf.AutoHeadingIDs | bf.HeadingIDs | bf.Footnotes | bf.NoEmptyLineBeforeBlock 25 | ) 26 | 27 | type Markdown struct { 28 | body []byte 29 | frontmatter map[string]any 30 | Path string 31 | } 32 | 33 | func (*Markdown) Ext() string { return ".md" } 34 | 35 | func (md *Markdown) Basename() string { 36 | return filepath.Base(md.Path) 37 | } 38 | 39 | // mdToHtml renders source markdown to html 40 | func mdToHtml(source []byte) []byte { 41 | return bf.Run( 42 | source, 43 | bf.WithNoExtensions(), 44 | bf.WithRenderer(bf.NewHTMLRenderer(bf.HTMLRendererParameters{Flags: bfFlags})), 45 | bf.WithExtensions(bfExts), 46 | ) 47 | } 48 | 49 | // template checks the frontmatter for a specified template or falls back 50 | // to the default template -- to which it, well, templates whatever is in 51 | // data and writes it to dest. 52 | func (md *Markdown) template(dest, tmplDir string, data any) error { 53 | metaTemplate, ok := md.frontmatter["template"].(string) 54 | if !ok || metaTemplate == "" { 55 | metaTemplate = config.Config.DefaultTemplate 56 | } 57 | 58 | tmpl := template.NewTmpl() 59 | tmpl.SetFuncs(gotmpl.FuncMap{ 60 | "parsedate": func(s string) time.Time { 61 | date, _ := time.Parse("2006-01-02", s) 62 | return date 63 | }, 64 | }) 65 | if err := tmpl.Load(tmplDir); err != nil { 66 | return err 67 | } 68 | 69 | return tmpl.Write(dest, metaTemplate, data) 70 | } 71 | 72 | // extractFrontmatter takes the source markdown page, extracts the frontmatter 73 | // and body. The body is converted from markdown to html here. 74 | func (md *Markdown) extractFrontmatter(source []byte) error { 75 | r := bytes.NewReader(source) 76 | rest, err := frontmatter.Parse(r, &md.frontmatter) 77 | if err != nil { 78 | return err 79 | } 80 | md.body = mdToHtml(rest) 81 | return nil 82 | } 83 | 84 | func (md *Markdown) Frontmatter() map[string]any { 85 | return md.frontmatter 86 | } 87 | 88 | func (md *Markdown) Body() string { 89 | return string(md.body) 90 | } 91 | 92 | type templateData struct { 93 | Cfg config.ConfigYaml 94 | Meta map[string]any 95 | Body string 96 | Extra any 97 | } 98 | 99 | func (md *Markdown) Render(dest string, data any, drafts bool) error { 100 | source, err := os.ReadFile(md.Path) 101 | if err != nil { 102 | return fmt.Errorf("markdown: error reading file: %w", err) 103 | } 104 | 105 | err = md.extractFrontmatter(source) 106 | if err != nil { 107 | return fmt.Errorf("markdown: error extracting frontmatter: %w", err) 108 | } 109 | 110 | isDraft, ok := md.frontmatter["draft"].(bool) 111 | if ok && isDraft { 112 | if !drafts { 113 | fmt.Printf("vite: skipping draft %s\n", md.Path) 114 | return nil 115 | } 116 | fmt.Printf("vite: rendering draft %s\n", md.Path) 117 | } 118 | 119 | err = md.template(dest, types.TemplatesDir, templateData{ 120 | config.Config, 121 | md.frontmatter, 122 | string(md.body), 123 | data, 124 | }) 125 | if err != nil { 126 | return fmt.Errorf("markdown: failed to render to destination %s: %w", dest, err) 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /formats/yaml/yaml.go: -------------------------------------------------------------------------------- 1 | package yaml 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | gotmpl "text/template" 8 | "time" 9 | 10 | "gopkg.in/yaml.v3" 11 | "tangled.sh/icyphox.sh/vite/config" 12 | "tangled.sh/icyphox.sh/vite/template" 13 | "tangled.sh/icyphox.sh/vite/types" 14 | ) 15 | 16 | type YAML struct { 17 | Path string 18 | 19 | meta map[string]any 20 | } 21 | 22 | func (*YAML) Ext() string { return ".yaml" } 23 | func (*YAML) Body() string { return "" } 24 | func (y *YAML) Basename() string { return filepath.Base(y.Path) } 25 | 26 | func (y *YAML) Frontmatter() map[string]any { 27 | return y.meta 28 | } 29 | 30 | type templateData struct { 31 | Cfg config.ConfigYaml 32 | Meta map[string]any 33 | Yaml map[string]any 34 | Body string 35 | } 36 | 37 | func (y *YAML) template(dest, tmplDir string, data any) error { 38 | var metaTemplate string 39 | if templateVal, ok := y.meta["template"]; ok { 40 | if strVal, isStr := templateVal.(string); isStr { 41 | metaTemplate = strVal 42 | } 43 | } 44 | if metaTemplate == "" { 45 | metaTemplate = config.Config.DefaultTemplate 46 | } 47 | 48 | tmpl := template.NewTmpl() 49 | tmpl.SetFuncs(gotmpl.FuncMap{ 50 | "parsedate": func(s string) time.Time { 51 | date, _ := time.Parse("2006-01-02", s) 52 | return date 53 | }, 54 | }) 55 | if err := tmpl.Load(tmplDir); err != nil { 56 | return err 57 | } 58 | 59 | return tmpl.Write(dest, metaTemplate, data) 60 | } 61 | 62 | func (y *YAML) Render(dest string, data any, drafts bool) error { 63 | yamlBytes, err := os.ReadFile(y.Path) 64 | if err != nil { 65 | return fmt.Errorf("yaml: failed to read file: %s: %w", y.Path, err) 66 | } 67 | 68 | yamlData := map[string]any{} 69 | err = yaml.Unmarshal(yamlBytes, yamlData) 70 | if err != nil { 71 | return fmt.Errorf("yaml: failed to unmarshal yaml file: %s: %w", y.Path, err) 72 | } 73 | 74 | metaInterface, ok := yamlData["meta"].(map[string]any) 75 | if !ok { 76 | return fmt.Errorf("yaml: meta section is not a map: %s", y.Path) 77 | } 78 | 79 | stringMeta := make(map[string]any) 80 | for k, v := range metaInterface { 81 | stringMeta[k] = convertToString(v) 82 | } 83 | 84 | y.meta = stringMeta 85 | 86 | err = y.template(dest, types.TemplatesDir, templateData{ 87 | config.Config, 88 | y.meta, 89 | yamlData, 90 | "", 91 | }) 92 | if err != nil { 93 | return fmt.Errorf("yaml: failed to render to destination %s: %w", dest, err) 94 | } 95 | 96 | return nil 97 | } 98 | 99 | func convertToString(value any) string { 100 | // Infer type and convert to string 101 | switch v := value.(type) { 102 | case string: 103 | return v 104 | case time.Time: 105 | return v.Format("2006-01-02") 106 | default: 107 | return fmt.Sprintf("%v", v) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tangled.sh/icyphox.sh/vite 2 | 3 | go 1.22 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | git.icyphox.sh/grayfriday v0.0.0-20221126034429-23c704183914 9 | github.com/adrg/frontmatter v0.2.0 10 | github.com/alecthomas/chroma v0.10.0 11 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b 12 | ) 13 | 14 | require ( 15 | github.com/BurntSushi/toml v0.3.1 // indirect 16 | github.com/alecthomas/chroma/v2 v2.16.0 // indirect 17 | github.com/dlclark/regexp2 v1.11.5 // indirect 18 | gopkg.in/yaml.v2 v2.3.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | git.icyphox.sh/grayfriday v0.0.0-20221126034429-23c704183914 h1:4f0PFapEZUYls6gEjWMA82jOm0s5E4I9p23QpXv1sSg= 2 | git.icyphox.sh/grayfriday v0.0.0-20221126034429-23c704183914/go.mod h1:/wTbXjiiGlIYuqC6rVyD9ml88NWw7tujy3pOqj8kkKc= 3 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= 6 | github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= 7 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 8 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 9 | github.com/alecthomas/chroma/v2 v2.16.0 h1:QC5ZMizk67+HzxFDjQ4ASjni5kWBTGiigRG1u23IGvA= 10 | github.com/alecthomas/chroma/v2 v2.16.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 15 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 16 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 17 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 18 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 19 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 21 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 22 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 26 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 27 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 29 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 30 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Anirudh Oppiliappan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "tangled.sh/icyphox.sh/vite/commands" 8 | ) 9 | 10 | func main() { 11 | args := os.Args 12 | 13 | helpStr := `usage: vite [options] 14 | 15 | A simple and minimal static site generator. 16 | 17 | options: 18 | init PATH create vite project at PATH 19 | build [--drafts] builds the current project 20 | new PATH create a new markdown post 21 | serve [HOST:PORT] serves the 'build' directory 22 | ` 23 | 24 | if len(args) <= 1 { 25 | fmt.Println(helpStr) 26 | return 27 | } 28 | 29 | switch args[1] { 30 | case "init": 31 | if len(args) <= 2 { 32 | fmt.Println(helpStr) 33 | return 34 | } 35 | initPath := args[2] 36 | if err := commands.Init(initPath); err != nil { 37 | fmt.Fprintf(os.Stderr, "error: init: %+v\n", err) 38 | } 39 | 40 | case "build": 41 | var drafts bool 42 | if len(args) > 2 && args[2] == "--drafts" { 43 | drafts = true 44 | } 45 | if err := commands.Build(drafts); err != nil { 46 | fmt.Fprintf(os.Stderr, "error: build: %+v\n", err) 47 | } 48 | 49 | case "new": 50 | if len(args) <= 2 { 51 | fmt.Println(helpStr) 52 | return 53 | } 54 | newPath := args[2] 55 | if err := commands.New(newPath); err != nil { 56 | fmt.Fprintf(os.Stderr, "error: new: %+v\n", err) 57 | } 58 | case "serve": 59 | var addr string 60 | if len(args) == 3 { 61 | addr = args[2] 62 | } else { 63 | addr = ":9191" 64 | } 65 | if err := commands.Serve(addr); err != nil { 66 | fmt.Fprintf(os.Stderr, "error: serve: %+v\n", err) 67 | } 68 | default: 69 | fmt.Println(helpStr) 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | default: 2 | go build -o vite 3 | 4 | install: 5 | install -Dm755 vite $(DESTDIR)$(PREFIX)/bin/vite 6 | 7 | uninstall: 8 | @rm -f $(DESTDIR)$(PREFIX)/bin/vite 9 | -------------------------------------------------------------------------------- /readme: -------------------------------------------------------------------------------- 1 | vite 2 | ---- 3 | 4 | A fast (this time, actually) and minimal static site generator. 5 | 6 | INSTALLING 7 | 8 | go install tangled.sh/icyphox.sh/vite@latest 9 | 10 | 11 | USAGE 12 | 13 | usage: vite [options] 14 | 15 | A simple and minimal static site generator. 16 | 17 | options: 18 | init PATH create vite project at PATH 19 | build builds the current project 20 | new PATH create a new markdown post 21 | serve [HOST:PORT] serves the 'build' directory 22 | 23 | 24 | CONFIG 25 | 26 | The configuration is unmarshalled from a config.yaml file, into the 27 | below struct: 28 | 29 | type ConfigYaml struct { 30 | Title string `yaml:"title"` 31 | Desc string `yaml:"description"` 32 | DefaultTemplate string `yaml:"-"` 33 | Author struct { 34 | Name string `yaml:"name"` 35 | Email string `yaml:"email"` 36 | } `yaml:"author"` 37 | URL string `yaml:"url"` 38 | PreBuild []string `yaml:"preBuild"` 39 | PostBuild []string `yaml:"postBuild"` 40 | } 41 | 42 | Example config: https://tangled.sh/@icyphox.sh/site/blob/master/config.yaml 43 | 44 | 45 | SYNTAX HIGHLIGHTING 46 | 47 | vite uses chroma (https://github.com/alecthomas/chroma) for syntax 48 | highlighting. Note that CSS is not provided, and will have to be 49 | included by the user in the templates. A sample style can be generated 50 | by running: 51 | 52 | go run contrib/style.go > syntax.css 53 | 54 | 55 | SPECIAL META DIRECTIVES 56 | 57 | • draft: sets a post to draft (boolean) and will only be rendered if 58 | the build command is run with the --drafts flag. 59 | • atroot: sets a post to be also rendered at the root of the site. 60 | 61 | 62 | TEMPLATING 63 | 64 | Non-index templates have access to the below objects: 65 | • Cfg: object of ConfigYaml 66 | • Meta: map[string]string of the page's frontmatter metadata 67 | • Body: Contains the HTML 68 | 69 | 70 | Index templates have access to everything above, and an Extra object, 71 | which is a slice of types.Post containing Body and Meta. This is useful 72 | for iterating through to generate an index page. 73 | Example: https://git.icyphox.sh/site/tree/templates/index.html 74 | 75 | Templates are written as standard Go templates (ref: 76 | https://godocs.io/text/template), and can be loaded recursively. 77 | Consider the below template structure: 78 | 79 | templates/ 80 | |-- blog.html 81 | |-- index.html 82 | |-- project/ 83 | |-- index.html 84 | `-- project.html 85 | 86 | The templates under project/ are referenced as project/index.html. 87 | This deserves mention because Go templates don't recurse into 88 | subdirectories by default (template.ParseGlob uses filepath.Glob, and 89 | doesn't support deep-matching, i.e. **). 90 | 91 | vite also supports templating generic YAML files. Take for instance, 92 | pages/reading.yaml (https://git.icyphox.sh/site/blob/master/pages/reading.yaml): 93 | 94 | meta: 95 | template: reading.html 96 | title: reading 97 | subtitle: Tracking my reading. 98 | description: I use this page to track my reading. 99 | 100 | books: 101 | - 2024: 102 | - name: Dune Messiah 103 | link: https://en.wikipedia.org/wiki/Dune_Messiah 104 | author: Frank Herbert 105 | status: now reading 106 | - 2023: 107 | - name: Dune 108 | link: https://en.wikipedia.org/wiki/Dune_(novel) 109 | author: Frank Herbert 110 | status: finished 111 | 112 | vite will look for a 'meta' key in the YAML file, and use the 'template' 113 | specified to render the page. The rest of the YAML file is available to 114 | you in the template as a map[string]interface{} called Yaml. 115 | 116 | 117 | More templating examples can be found at: 118 | https://git.icyphox.sh/site/tree/templates 119 | 120 | 121 | FEEDS 122 | 123 | Atom feeds are generated for all directories under pages/. So 124 | pages/foo will have a Atom feed at build/foo/feed.xml. 125 | 126 | 127 | FILE TREE 128 | 129 | . 130 | |-- build/ 131 | |-- config.yaml 132 | |-- pages/ 133 | |-- static/ 134 | |-- templates/ 135 | 136 | The entire static/ directory gets copied over to build/, and can be 137 | used to reference static assets -- css, images, etc. pages/ supports 138 | only nesting one directory deep; for example: pages/blog/*.md will 139 | render, but pages/blog/foo/*.md will not. 140 | 141 | 142 | BUGS 143 | 144 | Or rather, (undocumented) features. There's probably a couple. If you are 145 | actually using this, feel free to reach out and I can try to help. 146 | -------------------------------------------------------------------------------- /template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "text/template" 8 | ) 9 | 10 | type Tmpl struct { 11 | *template.Template 12 | } 13 | 14 | func NewTmpl() *Tmpl { 15 | tmpl := Tmpl{} 16 | tmpl.Template = template.New("") 17 | return &tmpl 18 | } 19 | 20 | func (t *Tmpl) SetFuncs(funcMap template.FuncMap) { 21 | t.Template = t.Template.Funcs(funcMap) 22 | } 23 | 24 | func (t *Tmpl) Load(dir string) (err error) { 25 | if dir, err = filepath.Abs(dir); err != nil { 26 | return err 27 | } 28 | 29 | root := t.Template 30 | 31 | if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) (_ error) { 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if filepath.Ext(path) != ".html" { 37 | return 38 | } 39 | 40 | var rel string 41 | if rel, err = filepath.Rel(dir, path); err != nil { 42 | return err 43 | } 44 | 45 | rel = strings.Join(strings.Split(rel, string(os.PathSeparator)), "/") 46 | newTmpl := root.New(rel) 47 | 48 | var b []byte 49 | if b, err = os.ReadFile(path); err != nil { 50 | return err 51 | } 52 | 53 | _, err = newTmpl.Parse(string(b)) 54 | return err 55 | }); err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (t *Tmpl) Write(dest string, name string, data interface{}) error { 62 | w, err := os.Create(dest) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | return t.ExecuteTemplate(w, name, data) 68 | } 69 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | BuildDir = "build" 5 | PagesDir = "pages" 6 | TemplatesDir = "templates" 7 | StaticDir = "static" 8 | ) 9 | 10 | type File interface { 11 | Ext() string 12 | // Render takes any arbitrary data and combines that with the global config, 13 | // page frontmatter and the body, as template params. Templates are read 14 | // from types.TemplateDir and the final html is written to dest, 15 | // with necessary directories being created. 16 | Render(dest string, data interface{}, drafts bool) error 17 | 18 | // Frontmatter will not be populated if Render hasn't been called. 19 | Frontmatter() map[string]any 20 | // Body will not be populated if Render hasn't been called. 21 | Body() string 22 | Basename() string 23 | } 24 | 25 | // Only used for building indexes and Atom feeds 26 | type Post struct { 27 | Meta map[string]any 28 | // HTML-formatted body of post 29 | Body string 30 | } 31 | --------------------------------------------------------------------------------