diff --git a/cmd/lit/lit.go b/cmd/lit/lit.go index cda7c19..8bf092a 100644 --- a/cmd/lit/lit.go +++ b/cmd/lit/lit.go @@ -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 ( @@ -38,7 +42,37 @@ func main() { } if *in == "" { - fmt.Printf("lit -in \n") + fmt.Println("usage: lit -in [-out ] [-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(" ") + 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 ") + } + processDirectory(*in, *out) return } @@ -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) } } diff --git a/cmd/lit/lit_test.go b/cmd/lit/lit_test.go new file mode 100644 index 0000000..744fb90 --- /dev/null +++ b/cmd/lit/lit_test.go @@ -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 := ` + +test +{{ body . }} +` + 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 := ` + +¶ ⦊ 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), "") { + 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 := `
{{ body . }}
` + if err := os.WriteFile(filepath.Join(inDir, "custom.tmpl"), []byte(customTmpl), 0644); err != nil { + t.Fatal(err) + } + + // create .lit file specifying custom template + lit1 := ` + +¶ ⦊ 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), "
") { + 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) + } +} diff --git a/write.go b/write.go index 0ead53d..27096e2 100644 --- a/write.go +++ b/write.go @@ -1296,8 +1296,7 @@ func writeHTML(val tokenStringer, s *htmlWriteState, w io.Writer, n *Node, opts if opts.InMath { log.Fatal("can't be in a link node in math") } - w.Write([]byte(fmt.Sprintf("")) + w.Write([]byte(fmt.Sprintf("", getAttr(n.Attr, "href")))) for c := n.FirstChild; c != nil; c = c.NextSibling { writeHTML(val, s, w, c, NoPrefix(opts)) }