Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 191 additions & 31 deletions cmd/lit/lit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@ import (
"flag"
"fmt"
"io"
"io/fs"
"log"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"text/template"

"github.com/nlandolfi/lit"
)

var inmode = flag.String("i", "", "the type of the input file")
var in = flag.String("in", "", "in file, required")
var outmode = flag.String("o", "", "the type of the output file {debug|lit|tex|html|slides|tmpl}")
var out = flag.String("out", "", "out file, if unset writes to stdout")
var tmpl = flag.String("tmpl", "text.tmpl", "in case -o tmpl, the template file to execute")
var v = flag.Bool("v", false, "whether to print the version; exits after printing info")
var inmode = flag.String("i", "", "input format {lit|tex|html|csv}, auto-detected from extension")
var in = flag.String("in", "", "input file or directory (required)")
var outmode = flag.String("o", "", "output format {debug|lit|tex|html|slides|tmpl}")
var out = flag.String("out", "", "output file or directory; if dir input, -out dir is required")
var tmpl = flag.String("tmpl", "text.tmpl", "template file for -o tmpl mode")
var v = flag.Bool("v", false, "print version and exit")
var verbose = flag.Bool("verbose", false, "print file processing info")

// Set using link flags; e.g., -X main.Version=...
var (
Expand All @@ -38,7 +42,37 @@ func main() {
}

if *in == "" {
fmt.Printf("lit -in <filename>\n")
fmt.Println("usage: lit -in <file|dir> [-out <file|dir>] [-o format]")
fmt.Println()
fmt.Println("single file:")
fmt.Println(" lit -in foo.lit -o html # output to stdout")
fmt.Println(" lit -in foo.lit -out foo.html # output to file")
fmt.Println()
fmt.Println("directory (batch):")
fmt.Println(" lit -in src/ -out dist/ -o html # compile all .lit files")
fmt.Println()
fmt.Println("in directory mode, each .lit file can specify a template via yaml frontmatter:")
fmt.Println(" <!--yaml")
fmt.Println(" template: page.tmpl")
fmt.Println(" title: My Page")
fmt.Println(" -->")
fmt.Println()
fmt.Println("falls back to default.tmpl in input dir, or raw output if no template found.")
fmt.Println()
flag.PrintDefaults()
return
}

// check if input is a directory
fi, err := os.Stat(*in)
if err != nil {
log.Fatalf("stat %q: %v", *in, err)
}
if fi.IsDir() {
if *out == "" {
log.Fatalf("directory mode requires -out <dir>")
}
processDirectory(*in, *out)
return
}

Expand Down Expand Up @@ -128,34 +162,160 @@ func main() {
}
}

func execute(w io.Writer, t string, n *lit.Node) {
// Create a template, add the function map, and parse the text.
tmpl, err := template.New("").Funcs(
template.FuncMap{
"tex": func(n *lit.Node) string {
var b bytes.Buffer
lit.WriteTex(&b, n, &lit.WriteOpts{Prefix: " ", Indent: ""})
return b.String()
},
"texpi": func(n *lit.Node, pr, in string) string {
var b bytes.Buffer
lit.WriteTex(&b, n, &lit.WriteOpts{Prefix: pr, Indent: in})
return b.String()
},
"lit": func(n *lit.Node) string {
var b bytes.Buffer
lit.WriteLit(&b, n, &lit.WriteOpts{Prefix: "", Indent: " "})
return b.String()
},
},
).Parse(t)
func processDirectory(inDir, outDir string) {
// determine output extension based on -o flag
outExt := ".html"
switch *outmode {
case "tex":
outExt = ".tex"
case "lit":
outExt = ".lit"
}

// check for default template
defaultTmplPath := filepath.Join(inDir, "default.tmpl")
var defaultTmpl string
if bs, err := os.ReadFile(defaultTmplPath); err == nil {
defaultTmpl = string(bs)
}

err := filepath.WalkDir(inDir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}

// skip hidden files and directories
if strings.HasPrefix(d.Name(), ".") || strings.HasPrefix(d.Name(), "_") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}

// skip template files and non-.lit files
if d.IsDir() || filepath.Ext(p) == ".tmpl" || filepath.Ext(p) != ".lit" {
return nil
}

// compute relative path and output path
rel, err := filepath.Rel(inDir, p)
if err != nil {
return err
}
outPath := filepath.Join(outDir, strings.TrimSuffix(rel, ".lit")+outExt)

// ensure output directory exists
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}

// read and parse input file
bs, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("reading %s: %w", p, err)
}

n, err := lit.ParseLit(string(bs))
if err != nil {
return fmt.Errorf("parsing %s: %w", p, err)
}

// check for yaml frontmatter to find template
var tmplContent string
if n.FirstChild != nil && n.FirstChild.Type == lit.YAMLNode {
if t, ok := n.FirstChild.YAML["template"].(string); ok {
tmplPath := filepath.Join(inDir, t)
if bs, err := os.ReadFile(tmplPath); err == nil {
tmplContent = string(bs)
} else {
return fmt.Errorf("reading template %s: %w", tmplPath, err)
}
}
}
if tmplContent == "" {
tmplContent = defaultTmpl
}

// create output file
f, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("creating %s: %w", outPath, err)
}
defer f.Close()

// write output
if tmplContent != "" {
// use template, pass node directly like single-file mode
t, err := template.New("").Funcs(templateFuncs).Parse(tmplContent)
if err != nil {
return fmt.Errorf("parsing template for %s: %w", p, err)
}
if err := t.Execute(f, n); err != nil {
return fmt.Errorf("executing template for %s: %w", p, err)
}
} else {
// no template, output raw
switch *outmode {
case "tex":
lit.WriteTex(f, n, lit.DefaultWriteOpts)
case "lit":
lit.WriteLit(f, n, lit.DefaultWriteOpts)
default:
lit.WriteHTMLInBody(f, n, lit.DefaultWriteOpts)
}
}

if *verbose {
log.Printf("%s -> %s", p, outPath)
}
return nil
})

if err != nil {
log.Fatalf("template parsing: %s", err)
log.Fatalf("processing directory: %v", err)
}
}

// Run the template to verify the output.
err = tmpl.Execute(w, n)
var templateFuncs = template.FuncMap{
"tex": func(n *lit.Node) string {
var b bytes.Buffer
lit.WriteTex(&b, n, &lit.WriteOpts{Prefix: " ", Indent: ""})
return b.String()
},
"texpi": func(n *lit.Node, pr, in string) string {
var b bytes.Buffer
lit.WriteTex(&b, n, &lit.WriteOpts{Prefix: pr, Indent: in})
return b.String()
},
"lit": func(n *lit.Node) string {
var b bytes.Buffer
lit.WriteLit(&b, n, &lit.WriteOpts{Prefix: "", Indent: " "})
return b.String()
},
"html": func(n *lit.Node) string {
var b bytes.Buffer
lit.WriteHTML(&b, n, lit.DefaultWriteOpts)
return b.String()
},
"body": func(n *lit.Node) string {
var b bytes.Buffer
// skip yaml frontmatter, render rest
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == lit.YAMLNode {
continue
}
lit.WriteHTML(&b, c, lit.DefaultWriteOpts)
}
return b.String()
},
}

func execute(w io.Writer, t string, n *lit.Node) {
tmpl, err := template.New("").Funcs(templateFuncs).Parse(t)
if err != nil {
log.Fatalf("template parsing: %s", err)
}
if err = tmpl.Execute(w, n); err != nil {
log.Fatalf("template execution: %s", err)
}
}
Expand Down
141 changes: 141 additions & 0 deletions cmd/lit/lit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestProcessDirectory(t *testing.T) {
// create temp input dir
inDir := t.TempDir()
outDir := t.TempDir()

// create default template
tmpl := `<!DOCTYPE html>
<html>
<head><title>test</title></head>
<body>{{ body . }}</body>
</html>`
if err := os.WriteFile(filepath.Join(inDir, "default.tmpl"), []byte(tmpl), 0644); err != nil {
t.Fatal(err)
}

// create a .lit file with yaml frontmatter
lit1 := `<!--yaml
title: Hello
-->

¶ ⦊ Hello world ⦉
`
if err := os.WriteFile(filepath.Join(inDir, "index.lit"), []byte(lit1), 0644); err != nil {
t.Fatal(err)
}

// create nested dir
if err := os.MkdirAll(filepath.Join(inDir, "posts"), 0755); err != nil {
t.Fatal(err)
}

// create nested .lit file without frontmatter
lit2 := `¶ ⦊ A post ⦉
`
if err := os.WriteFile(filepath.Join(inDir, "posts", "one.lit"), []byte(lit2), 0644); err != nil {
t.Fatal(err)
}

// set flags and run
*in = inDir
*out = outDir
*outmode = "html"
processDirectory(inDir, outDir)

// check index.html exists and has content
indexHTML, err := os.ReadFile(filepath.Join(outDir, "index.html"))
if err != nil {
t.Fatalf("reading index.html: %v", err)
}
if !strings.Contains(string(indexHTML), "Hello world") {
t.Errorf("index.html missing content, got: %s", indexHTML)
}
if !strings.Contains(string(indexHTML), "<!DOCTYPE html>") {
t.Errorf("index.html missing doctype from template, got: %s", indexHTML)
}
// should not contain yaml block
if strings.Contains(string(indexHTML), "title: Hello") {
t.Errorf("index.html should not contain yaml frontmatter, got: %s", indexHTML)
}

// check posts/one.html exists
postHTML, err := os.ReadFile(filepath.Join(outDir, "posts", "one.html"))
if err != nil {
t.Fatalf("reading posts/one.html: %v", err)
}
if !strings.Contains(string(postHTML), "A post") {
t.Errorf("posts/one.html missing content, got: %s", postHTML)
}
}

func TestProcessDirectoryCustomTemplate(t *testing.T) {
inDir := t.TempDir()
outDir := t.TempDir()

// create custom template
customTmpl := `<article>{{ body . }}</article>`
if err := os.WriteFile(filepath.Join(inDir, "custom.tmpl"), []byte(customTmpl), 0644); err != nil {
t.Fatal(err)
}

// create .lit file specifying custom template
lit1 := `<!--yaml
template: custom.tmpl
-->

¶ ⦊ Custom content ⦉
`
if err := os.WriteFile(filepath.Join(inDir, "page.lit"), []byte(lit1), 0644); err != nil {
t.Fatal(err)
}

*in = inDir
*out = outDir
*outmode = "html"
processDirectory(inDir, outDir)

pageHTML, err := os.ReadFile(filepath.Join(outDir, "page.html"))
if err != nil {
t.Fatalf("reading page.html: %v", err)
}
if !strings.Contains(string(pageHTML), "<article>") {
t.Errorf("page.html should use custom template, got: %s", pageHTML)
}
if !strings.Contains(string(pageHTML), "Custom content") {
t.Errorf("page.html missing content, got: %s", pageHTML)
}
}

func TestProcessDirectoryNoTemplate(t *testing.T) {
inDir := t.TempDir()
outDir := t.TempDir()

// no template files - should output raw html
lit1 := `¶ ⦊ Raw content ⦉
`
if err := os.WriteFile(filepath.Join(inDir, "raw.lit"), []byte(lit1), 0644); err != nil {
t.Fatal(err)
}

*in = inDir
*out = outDir
*outmode = "html"
processDirectory(inDir, outDir)

rawHTML, err := os.ReadFile(filepath.Join(outDir, "raw.html"))
if err != nil {
t.Fatalf("reading raw.html: %v", err)
}
if !strings.Contains(string(rawHTML), "Raw content") {
t.Errorf("raw.html missing content, got: %s", rawHTML)
}
}
Loading