summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-11 12:00:44 -0600
committert <t@tjp.lol>2026-07-11 12:00:44 -0600
commit7485ba6b87993f8798aeeab2a1df6f1e9a815691 (patch)
tree1f07894af5111c73a1f3fd63497e3cfdf896b73b
Build the Weft static site generator
-rw-r--r--.gitignore3
-rw-r--r--README.md48
-rw-r--r--go.mod9
-rw-r--r--go.sum8
-rw-r--r--main.go696
-rw-r--r--main_test.go268
6 files changed, 1032 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1720837
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/weft
+*.test
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..00f4df5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# Weft
+
+Weft is the small static-site generator used by `tjp.lol`. It recursively turns
+`page.md` into `page.html` and renders explicit `*.html.tmpl` and `*.xml.tmpl`
+outputs in place.
+
+```sh
+go build -o weft .
+./weft ../tjp.lol
+```
+
+Markdown uses CommonMark with GFM tables, strikethrough, task lists, and
+autolinks. Raw Markdown HTML is disabled. Optional YAML frontmatter is exposed
+as `.Meta`; the first H1 becomes `.Title` and is removed from `.Sections`.
+
+## Site templates
+
+All non-underscore `.tmpl` files are loaded into one named template set. Files
+ending in `.html.tmpl` or `.xml.tmpl` also render to the same path without the
+`.tmpl` suffix. Other `.tmpl` files only define reusable templates.
+
+Markdown pages execute one of these required definitions:
+
+- `page` for ordinary paths
+- `weblog` below `weblog/`
+- `note` below `notes/`
+
+Template data exposes the current page fields (`.Title`, `.Meta`, `.Sections`,
+`.OutputPath`, and so on) plus `.Pages`, a map containing each Markdown page at
+every ancestor directory. Helpers are `rel from target`, `sortPages pages key`,
+`filterPages pages key value`, `slicePages pages start end`, `joinSections`,
+`date value layout`, `rfc3339 value`, and `xml value`. Use `xml` around joined
+HTML when embedding it as escaped Atom `type="html"` content.
+
+Go templates' built-in `slice` can select rendered sections, for example
+`joinSections (slice .Sections 1)` after rendering a weblog lede separately.
+
+Weft validates generated internal links and XML before changing the site. It
+tracks ownership in `.weft-generated.json`, refuses to replace untracked files,
+removes only tracked stale outputs, and installs a completed build with rollback
+on write failure. Files and whole subtrees beginning with `_` are ignored.
+
+For cron, schedule the build after Syncthing's settling window and use the
+host's lock and logging tools, for example:
+
+```cron
+17 * * * * flock -n /tmp/weft.lock /usr/local/bin/weft /srv/tjp.lol >>/var/log/weft.log 2>&1
+```
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..80fa1f1
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,9 @@
+module tjp.lol/weft
+
+go 1.24
+
+require (
+ github.com/yuin/goldmark v1.7.13
+ golang.org/x/net v0.42.0
+ gopkg.in/yaml.v3 v3.0.1
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..302552a
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,8 @@
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..412ba84
--- /dev/null
+++ b/main.go
@@ -0,0 +1,696 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ gmtext "github.com/yuin/goldmark/text"
+ "golang.org/x/net/html"
+ "gopkg.in/yaml.v3"
+)
+
+const manifestName = ".weft-generated.json"
+
+type Page struct {
+ SourcePath string
+ OutputPath string
+ URL string
+ CanonicalURL string
+ Title string
+ Meta map[string]any
+ ModTime time.Time
+ Sections []template.HTML
+}
+
+type templateData struct {
+ *Page
+ Pages map[string][]*Page
+}
+
+type source struct {
+ path string
+ output string
+ kind string
+ page *Page
+}
+
+type site struct {
+ root string
+ pages map[string][]*Page
+ sources []source
+ templates *template.Template
+ warnings []string
+}
+
+type manifest struct {
+ Outputs []string `json:"outputs"`
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ fmt.Fprintln(os.Stderr, "usage: weft <site-root>")
+ os.Exit(2)
+ }
+ if err := build(os.Args[1]); err != nil {
+ fmt.Fprintln(os.Stderr, "weft:", err)
+ os.Exit(1)
+ }
+}
+
+func build(root string) error {
+ abs, err := filepath.Abs(root)
+ if err != nil {
+ return err
+ }
+ st, err := os.Stat(abs)
+ if err != nil {
+ return err
+ }
+ if !st.IsDir() {
+ return fmt.Errorf("site root is not a directory: %s", abs)
+ }
+
+ s := &site{root: abs, pages: map[string][]*Page{}}
+ if err := s.discover(); err != nil {
+ return err
+ }
+ if err := s.loadTemplates(); err != nil {
+ return err
+ }
+ outputs, err := s.render()
+ if err != nil {
+ return err
+ }
+ if err := s.validate(outputs); err != nil {
+ return err
+ }
+ if err := s.write(outputs); err != nil {
+ return err
+ }
+ for _, warning := range s.warnings {
+ fmt.Fprintln(os.Stderr, "weft: warning:", warning)
+ }
+ return nil
+}
+
+func excluded(rel string) bool {
+ for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
+ if strings.HasPrefix(part, "_") || part == ".git" || part == ".jj" || strings.HasPrefix(part, ".weft-txn-") {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *site) discover() error {
+ outputs := map[string]string{}
+ var templatePaths []string
+ err := filepath.WalkDir(s.root, func(full string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ rel, err := filepath.Rel(s.root, full)
+ if err != nil || rel == "." {
+ return err
+ }
+ if excluded(rel) {
+ if entry.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ rel = filepath.ToSlash(rel)
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ switch {
+ case strings.HasSuffix(rel, ".md"):
+ out := strings.TrimSuffix(rel, ".md") + ".html"
+ if err := claim(outputs, out, rel); err != nil {
+ return err
+ }
+ page, warnings, err := s.parsePage(rel, out, info)
+ if err != nil {
+ return fmt.Errorf("parse %s: %w", rel, err)
+ }
+ s.warnings = append(s.warnings, warnings...)
+ s.sources = append(s.sources, source{path: rel, output: out, kind: "markdown", page: page})
+ s.addPage(page)
+ case strings.HasSuffix(rel, ".tmpl"):
+ templatePaths = append(templatePaths, rel)
+ var out string
+ if strings.HasSuffix(rel, ".html.tmpl") || strings.HasSuffix(rel, ".xml.tmpl") {
+ out = strings.TrimSuffix(rel, ".tmpl")
+ if err := claim(outputs, out, rel); err != nil {
+ return err
+ }
+ s.sources = append(s.sources, source{path: rel, output: out, kind: "template"})
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ slices.Sort(templatePaths)
+ // Keep all template paths as non-output sources so definitions are loaded.
+ for _, p := range templatePaths {
+ found := false
+ for _, src := range s.sources {
+ if src.path == p {
+ found = true
+ break
+ }
+ }
+ if !found {
+ s.sources = append(s.sources, source{path: p, kind: "partial"})
+ }
+ }
+ sort.SliceStable(s.sources, func(i, j int) bool { return s.sources[i].path < s.sources[j].path })
+ return nil
+}
+
+func claim(outputs map[string]string, output, input string) error {
+ if previous, ok := outputs[output]; ok {
+ return fmt.Errorf("output collision: %s and %s both target %s", previous, input, output)
+ }
+ outputs[output] = input
+ return nil
+}
+
+func (s *site) parsePage(rel, out string, info fs.FileInfo) (*Page, []string, error) {
+ raw, err := os.ReadFile(filepath.Join(s.root, filepath.FromSlash(rel)))
+ if err != nil {
+ return nil, nil, err
+ }
+ meta, body, err := frontmatter(raw)
+ if err != nil {
+ return nil, nil, err
+ }
+ md := goldmark.New(goldmark.WithExtensions(extension.GFM), goldmark.WithParserOptions(parser.WithAutoHeadingID()))
+ doc := md.Parser().Parse(gmtext.NewReader(body))
+ rewriteLinks(doc)
+ title := extractTitle(doc, body)
+ var warnings []string
+ if title == "" {
+ if value, ok := meta["title"].(string); ok && strings.TrimSpace(value) != "" {
+ title = strings.TrimSpace(value)
+ } else {
+ base := strings.TrimSuffix(path.Base(rel), path.Ext(rel))
+ title = strings.Title(strings.ReplaceAll(strings.ReplaceAll(base, "_", " "), "-", " ")) //nolint:staticcheck
+ warnings = append(warnings, fmt.Sprintf("%s has no H1; using filename-derived title %q", rel, title))
+ }
+ }
+ sections, err := renderSections(md, doc, body)
+ if err != nil {
+ return nil, nil, err
+ }
+ urlPath := "/" + out
+ if path.Base(out) == "index.html" {
+ urlPath = "/" + strings.TrimSuffix(out, "index.html")
+ }
+ return &Page{
+ SourcePath: rel, OutputPath: out, URL: urlPath,
+ CanonicalURL: "https://tjp.lol" + urlPath, Title: title, Meta: meta,
+ ModTime: info.ModTime(), Sections: sections,
+ }, warnings, nil
+}
+
+func frontmatter(raw []byte) (map[string]any, []byte, error) {
+ meta := map[string]any{}
+ if !bytes.HasPrefix(raw, []byte("---\n")) && !bytes.HasPrefix(raw, []byte("---\r\n")) {
+ return meta, raw, nil
+ }
+ lines := bytes.SplitAfter(raw, []byte("\n"))
+ end, offset := -1, len(lines[0])
+ for i := 1; i < len(lines); i++ {
+ if strings.TrimSpace(string(lines[i])) == "---" {
+ end = i
+ break
+ }
+ offset += len(lines[i])
+ }
+ if end < 0 {
+ return nil, nil, errors.New("unterminated YAML frontmatter")
+ }
+ front := bytes.Join(lines[1:end], nil)
+ if err := yaml.Unmarshal(front, &meta); err != nil {
+ return nil, nil, fmt.Errorf("YAML frontmatter: %w", err)
+ }
+ offset += len(lines[end])
+ return meta, raw[offset:], nil
+}
+
+func extractTitle(doc ast.Node, source []byte) string {
+ for node := doc.FirstChild(); node != nil; node = node.NextSibling() {
+ heading, ok := node.(*ast.Heading)
+ if !ok || heading.Level != 1 {
+ continue
+ }
+ title := strings.TrimSpace(string(heading.Text(source)))
+ doc.RemoveChild(doc, node)
+ return title
+ }
+ return ""
+}
+
+func rewriteLinks(root ast.Node) {
+ _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ if link, ok := node.(*ast.Link); ok {
+ link.Destination = rewriteDestination(link.Destination)
+ }
+ return ast.WalkContinue, nil
+ })
+}
+
+func rewriteDestination(destination []byte) []byte {
+ raw := string(destination)
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "" || u.Host != "" || strings.HasPrefix(raw, "#") || !strings.HasSuffix(strings.ToLower(u.Path), ".md") {
+ return destination
+ }
+ u.Path = u.Path[:len(u.Path)-3] + ".html"
+ return []byte(u.String())
+}
+
+func renderSections(md goldmark.Markdown, doc ast.Node, source []byte) ([]template.HTML, error) {
+ var sections []template.HTML
+ for node := doc.FirstChild(); node != nil; {
+ next := node.NextSibling()
+ doc.RemoveChild(doc, node)
+ fragment := ast.NewDocument()
+ fragment.AppendChild(fragment, node)
+ var out bytes.Buffer
+ if err := md.Renderer().Render(&out, source, fragment); err != nil {
+ return nil, err
+ }
+ sections = append(sections, template.HTML(out.String())) // Goldmark raw HTML is disabled.
+ node = next
+ }
+ return sections, nil
+}
+
+func (s *site) addPage(page *Page) {
+ dir := path.Dir(page.OutputPath)
+ if dir == "." {
+ dir = ""
+ }
+ for {
+ s.pages[dir] = append(s.pages[dir], page)
+ if dir == "" {
+ break
+ }
+ dir = path.Dir(dir)
+ if dir == "." {
+ dir = ""
+ }
+ }
+}
+
+func (s *site) loadTemplates() error {
+ t := template.New("weft").Funcs(templateFuncs())
+ for _, src := range s.sources {
+ if src.kind != "template" && src.kind != "partial" {
+ continue
+ }
+ contents, err := os.ReadFile(filepath.Join(s.root, filepath.FromSlash(src.path)))
+ if err != nil {
+ return err
+ }
+ if _, err := t.New(src.path).Parse(string(contents)); err != nil {
+ return fmt.Errorf("parse template %s: %w", src.path, err)
+ }
+ }
+ s.templates = t
+ return nil
+}
+
+func templateFuncs() template.FuncMap {
+ return template.FuncMap{
+ "rel": relativePath,
+ "sortPages": sortPages,
+ "filterPages": filterPages,
+ "slicePages": slicePages,
+ "joinSections": joinSections,
+ "xml": func(value any) string { return fmt.Sprint(value) },
+ "date": func(value any, layout string) string { t, _ := asTime(value); return t.Format(layout) },
+ "rfc3339": func(value any) string { t, _ := asTime(value); return t.Format(time.RFC3339) },
+ }
+}
+
+func relativePath(from, target string) string {
+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") || strings.HasPrefix(target, "#") {
+ return target
+ }
+ fromDir := path.Dir(strings.TrimPrefix(from, "/"))
+ if fromDir == "." {
+ fromDir = ""
+ }
+ target = strings.TrimPrefix(target, "/")
+ rel, err := filepath.Rel(filepath.FromSlash(fromDir), filepath.FromSlash(target))
+ if err != nil {
+ return target
+ }
+ return filepath.ToSlash(rel)
+}
+
+func sortPages(pages []*Page, key string) []*Page {
+ result := slices.Clone(pages)
+ sort.SliceStable(result, func(i, j int) bool {
+ if key == "mod_time" {
+ return result[i].ModTime.After(result[j].ModTime)
+ }
+ if key == "title" {
+ return strings.ToLower(result[i].Title) < strings.ToLower(result[j].Title)
+ }
+ a, _ := asTime(result[i].Meta[key])
+ b, _ := asTime(result[j].Meta[key])
+ return a.After(b)
+ })
+ return result
+}
+
+func filterPages(pages []*Page, key string, value any) []*Page {
+ var result []*Page
+ for _, page := range pages {
+ var actual any
+ switch key {
+ case "title":
+ actual = page.Title
+ case "output":
+ actual = page.OutputPath
+ default:
+ actual = page.Meta[key]
+ }
+ if fmt.Sprint(actual) == fmt.Sprint(value) {
+ result = append(result, page)
+ }
+ }
+ return result
+}
+
+func slicePages(pages []*Page, start, end int) []*Page {
+ if start < 0 {
+ start = 0
+ }
+ if start > len(pages) {
+ start = len(pages)
+ }
+ if end < start {
+ end = start
+ }
+ if end > len(pages) {
+ end = len(pages)
+ }
+ return pages[start:end]
+}
+
+func joinSections(sections []template.HTML) template.HTML {
+ var out strings.Builder
+ for _, section := range sections {
+ out.WriteString(string(section))
+ }
+ return template.HTML(out.String())
+}
+
+func asTime(value any) (time.Time, bool) {
+ switch v := value.(type) {
+ case time.Time:
+ return v, true
+ case string:
+ for _, layout := range []string{time.RFC3339, "2006-01-02"} {
+ if parsed, err := time.Parse(layout, v); err == nil {
+ return parsed, true
+ }
+ }
+ }
+ return time.Time{}, false
+}
+
+func (s *site) render() (map[string][]byte, error) {
+ outputs := map[string][]byte{}
+ for _, src := range s.sources {
+ if src.output == "" {
+ continue
+ }
+ var current *Page
+ name := src.path
+ if src.kind == "markdown" {
+ current = src.page
+ name = "page"
+ if src.output == "weblog" || strings.HasPrefix(src.output, "weblog/") {
+ name = "weblog"
+ }
+ if src.output == "notes" || strings.HasPrefix(src.output, "notes/") {
+ name = "note"
+ }
+ } else {
+ current = &Page{SourcePath: src.path, OutputPath: src.output, URL: "/" + src.output, CanonicalURL: "https://tjp.lol/" + src.output, Meta: map[string]any{}}
+ }
+ if s.templates.Lookup(name) == nil {
+ return nil, fmt.Errorf("%s requires missing template %q", src.path, name)
+ }
+ var out bytes.Buffer
+ if err := s.templates.ExecuteTemplate(&out, name, templateData{Page: current, Pages: s.pages}); err != nil {
+ return nil, fmt.Errorf("render %s: %w", src.path, err)
+ }
+ outputs[src.output] = out.Bytes()
+ }
+ return outputs, nil
+}
+
+func (s *site) validate(outputs map[string][]byte) error {
+ tracked, err := s.readManifest()
+ if err != nil {
+ return err
+ }
+ for output, contents := range outputs {
+ if strings.HasSuffix(output, ".xml") {
+ var value struct{ XMLName xml.Name }
+ if err := xml.Unmarshal(contents, &value); err != nil {
+ return fmt.Errorf("invalid XML in %s: %w", output, err)
+ }
+ if output == "feed.xml" && (value.XMLName.Local != "feed" || value.XMLName.Space != "http://www.w3.org/2005/Atom") {
+ return fmt.Errorf("feed.xml is not an Atom 1.0 feed")
+ }
+ }
+ if strings.HasSuffix(output, ".html") {
+ if err := s.validateHTMLLinks(output, contents, outputs, tracked); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func (s *site) validateHTMLLinks(output string, contents []byte, outputs map[string][]byte, tracked map[string]bool) error {
+ z := html.NewTokenizer(bytes.NewReader(contents))
+ for {
+ tokenType := z.Next()
+ if tokenType == html.ErrorToken {
+ if errors.Is(z.Err(), io.EOF) {
+ return nil
+ }
+ return fmt.Errorf("parse HTML %s: %w", output, z.Err())
+ }
+ if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken {
+ continue
+ }
+ token := z.Token()
+ if token.Data != "a" {
+ continue
+ }
+ for _, attr := range token.Attr {
+ if attr.Key != "href" {
+ continue
+ }
+ if err := s.validateLink(output, attr.Val, outputs, tracked); err != nil {
+ return err
+ }
+ }
+ }
+}
+
+func (s *site) validateLink(from, raw string, outputs map[string][]byte, tracked map[string]bool) error {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("invalid link in %s: %q", from, raw)
+ }
+ if u.Scheme != "" || u.Host != "" || u.Path == "" {
+ return nil
+ }
+ target := u.Path
+ if strings.HasPrefix(target, "/") {
+ target = strings.TrimPrefix(path.Clean(target), "/")
+ } else {
+ target = path.Clean(path.Join(path.Dir(from), target))
+ }
+ if strings.HasSuffix(u.Path, "/") {
+ target = path.Join(target, "index.html")
+ }
+ if target == ".." || strings.HasPrefix(target, "../") {
+ return fmt.Errorf("internal link escapes the site root in %s: %s", from, raw)
+ }
+ if _, ok := outputs[target]; ok {
+ return nil
+ }
+ full := filepath.Join(s.root, filepath.FromSlash(target))
+ if info, statErr := os.Stat(full); statErr == nil && !info.IsDir() && !tracked[target] {
+ return nil
+ }
+ indexTarget := path.Join(target, "index.html")
+ if info, statErr := os.Stat(filepath.Join(full, "index.html")); statErr == nil && !info.IsDir() && !tracked[indexTarget] {
+ return nil
+ }
+ return fmt.Errorf("broken internal link in %s: %s", from, raw)
+}
+
+func (s *site) readManifest() (map[string]bool, error) {
+ tracked := map[string]bool{}
+ raw, err := os.ReadFile(filepath.Join(s.root, manifestName))
+ if errors.Is(err, os.ErrNotExist) {
+ return tracked, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return nil, fmt.Errorf("read %s: %w", manifestName, err)
+ }
+ for _, output := range m.Outputs {
+ clean := path.Clean(output)
+ ext := path.Ext(clean)
+ if output == "" || output != clean || strings.Contains(output, `\`) || path.IsAbs(output) || strings.HasPrefix(clean, "../") || ext != ".html" && ext != ".xml" {
+ return nil, fmt.Errorf("unsafe path in %s: %q", manifestName, output)
+ }
+ tracked[output] = true
+ }
+ return tracked, nil
+}
+
+func (s *site) write(outputs map[string][]byte) error {
+ tracked, err := s.readManifest()
+ if err != nil {
+ return err
+ }
+ for output := range outputs {
+ _, statErr := os.Stat(filepath.Join(s.root, filepath.FromSlash(output)))
+ if statErr == nil && !tracked[output] {
+ return fmt.Errorf("refusing to overwrite untracked output %s", output)
+ }
+ if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
+ return statErr
+ }
+ }
+
+ txn, err := os.MkdirTemp(s.root, ".weft-txn-")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(txn)
+ stage := filepath.Join(txn, "stage")
+ backup := filepath.Join(txn, "backup")
+ if err := os.MkdirAll(stage, 0o755); err != nil {
+ return err
+ }
+ for output, contents := range outputs {
+ name := filepath.Join(stage, filepath.FromSlash(output))
+ if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(name, contents, 0o644); err != nil {
+ return err
+ }
+ }
+ paths := make([]string, 0, len(outputs))
+ for output := range outputs {
+ paths = append(paths, output)
+ }
+ slices.Sort(paths)
+ manifestBytes, _ := json.MarshalIndent(manifest{Outputs: paths}, "", " ")
+ manifestBytes = append(manifestBytes, '\n')
+ if err := os.WriteFile(filepath.Join(stage, manifestName), manifestBytes, 0o644); err != nil {
+ return err
+ }
+
+ var movedOld, installed []string
+ rollback := func() {
+ for i := len(installed) - 1; i >= 0; i-- {
+ _ = os.Remove(filepath.Join(s.root, filepath.FromSlash(installed[i])))
+ }
+ for i := len(movedOld) - 1; i >= 0; i-- {
+ from := filepath.Join(backup, filepath.FromSlash(movedOld[i]))
+ to := filepath.Join(s.root, filepath.FromSlash(movedOld[i]))
+ _ = os.MkdirAll(filepath.Dir(to), 0o755)
+ _ = os.Rename(from, to)
+ }
+ }
+ moveOld := func(rel string) error {
+ from := filepath.Join(s.root, filepath.FromSlash(rel))
+ if _, err := os.Stat(from); errors.Is(err, os.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ to := filepath.Join(backup, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
+ return err
+ }
+ if err := os.Rename(from, to); err != nil {
+ return err
+ }
+ movedOld = append(movedOld, rel)
+ return nil
+ }
+ for old := range tracked {
+ if _, wanted := outputs[old]; !wanted {
+ if err := moveOld(old); err != nil {
+ rollback()
+ return err
+ }
+ }
+ }
+ for _, rel := range append(paths, manifestName) {
+ if err := moveOld(rel); err != nil {
+ rollback()
+ return err
+ }
+ to := filepath.Join(s.root, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil {
+ rollback()
+ return err
+ }
+ if err := os.Rename(filepath.Join(stage, filepath.FromSlash(rel)), to); err != nil {
+ rollback()
+ return err
+ }
+ installed = append(installed, rel)
+ }
+ return nil
+}
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..cf40a29
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,268 @@
+package main
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func writeTestFile(t *testing.T, root, name, contents string) {
+ t.Helper()
+ full := filepath.Join(root, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(full, []byte(contents), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func testLayouts() string {
+ return `{{define "page"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{end}}
+{{define "weblog"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{end}}
+{{define "note"}}{{template "page" .}}{{end}}`
+}
+
+func TestMarkdownGFMFrontmatterTitleSectionsAndLinks(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", testLayouts())
+ writeTestFile(t, root, "other.md", "# Other\n\nTarget.\n")
+ writeTestFile(t, root, "page.md", `---
+summary: hello
+---
+# My *Page*
+
+First paragraph with [local](other.md#part), [web](https://example.com/a.md), and [fragment](#part).
+
+| A | B |
+| - | - |
+| 1 | 2 |
+
+<script>alert(1)</script>
+`)
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(filepath.Join(root, "page.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := string(raw)
+ for _, want := range []string{"<title>My Page</title>", `href="other.html#part"`, `href="https://example.com/a.md"`, `href="#part"`, "<table>"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("output missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "<h1") || strings.Contains(got, "<script>") {
+ t.Errorf("H1 duplicated or raw HTML rendered:\n%s", got)
+ }
+
+ s := &site{root: root, pages: map[string][]*Page{}}
+ if err := s.discover(); err != nil {
+ t.Fatal(err)
+ }
+ var page *Page
+ for _, src := range s.sources {
+ if src.path == "page.md" {
+ page = src.page
+ }
+ }
+ if page == nil || page.Meta["summary"] != "hello" || len(page.Sections) != 3 {
+ t.Fatalf("unexpected page model: %#v", page)
+ }
+}
+
+func TestFilenameFallbackWarns(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "no-title.md", "Just text.\n")
+ s := &site{root: root, pages: map[string][]*Page{}}
+ if err := s.discover(); err != nil {
+ t.Fatal(err)
+ }
+ if len(s.warnings) != 1 || s.sources[0].page.Title != "No Title" {
+ t.Fatalf("warning/title = %#v, %q", s.warnings, s.sources[0].page.Title)
+ }
+}
+
+func TestRecursiveInventoryAndUnderscoreExclusion(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "notes/recipes/cocktails/old.md", "# Old\n")
+ writeTestFile(t, root, "notes/_private/secret.md", "# Secret\n")
+ writeTestFile(t, root, "notes/_draft.md", "# Draft\n")
+ s := &site{root: root, pages: map[string][]*Page{}}
+ if err := s.discover(); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"", "notes", "notes/recipes", "notes/recipes/cocktails"} {
+ if len(s.pages[key]) != 1 || s.pages[key][0].Title != "Old" {
+ t.Errorf("Pages[%q] = %#v", key, s.pages[key])
+ }
+ }
+ if len(s.sources) != 1 {
+ t.Fatalf("underscore sources were discovered: %#v", s.sources)
+ }
+}
+
+func TestOutputCollision(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "index.md", "# Index\n")
+ writeTestFile(t, root, "index.html.tmpl", "hello")
+ s := &site{root: root, pages: map[string][]*Page{}}
+ err := s.discover()
+ if err == nil || !strings.Contains(err.Error(), "output collision") {
+ t.Fatalf("got %v", err)
+ }
+}
+
+func TestRelativePath(t *testing.T) {
+ cases := map[string]string{
+ relativePath("index.html", "style.css"): "style.css",
+ relativePath("notes/deep/page.html", "style.css"): "../../style.css",
+ relativePath("notes/page.html", "/feed.xml"): "../feed.xml",
+ relativePath("notes/page.html", "https://example.com"): "https://example.com",
+ }
+ for got, want := range cases {
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ }
+}
+
+func TestPathSelectsMarkdownLayout(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}page:{{.Title}}{{end}}{{define "weblog"}}post:{{.Title}}{{end}}{{define "note"}}note:{{.Title}}{{end}}`)
+ writeTestFile(t, root, "about.md", "# About\n")
+ writeTestFile(t, root, "weblog/post.md", "# Post\n")
+ writeTestFile(t, root, "notes/item.md", "# Item\n")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ for name, want := range map[string]string{"about.html": "page:About", "weblog/post.html": "post:Post", "notes/item.html": "note:Item"} {
+ raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != want {
+ t.Errorf("%s = %q, want %q", name, raw, want)
+ }
+ }
+}
+
+func TestAtomValidSortedAndContainsFullEscapedContent(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", testLayouts())
+ writeTestFile(t, root, "weblog/older.md", "---\npost_date: 2024-01-01\n---\n# Older\n\nOld full body.\n")
+ writeTestFile(t, root, "weblog/newer.md", "---\npost_date: 2025-01-01\nupdated: 2025-02-01\n---\n# Newer\n\nNew **full** body.\n")
+ writeTestFile(t, root, "feed.xml.tmpl", `<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"><title>Site</title>{{range sortPages (index .Pages "weblog") "post_date"}}<entry><title>{{.Title}}</title><id>{{.CanonicalURL}}</id><updated>{{if index .Meta "updated"}}{{rfc3339 (index .Meta "updated")}}{{else}}{{rfc3339 (index .Meta "post_date")}}{{end}}</updated><content type="html">{{xml (joinSections .Sections)}}</content></entry>{{end}}</feed>`)
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := os.ReadFile(filepath.Join(root, "feed.xml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var sink struct{}
+ if err := xml.Unmarshal(raw, &sink); err != nil {
+ t.Fatalf("invalid Atom: %v\n%s", err, raw)
+ }
+ got := string(raw)
+ if strings.Index(got, "Newer") > strings.Index(got, "Older") {
+ t.Errorf("feed is not newest first:\n%s", got)
+ }
+ if !strings.Contains(got, "New &lt;strong&gt;full&lt;/strong&gt; body.") {
+ t.Errorf("full HTML content not XML escaped:\n%s", got)
+ }
+}
+
+func TestGeneratedOwnershipStaleCleanupAndFailedBuildPreservation(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", testLayouts())
+ writeTestFile(t, root, "one.md", "# One\n\nBody.\n")
+ writeTestFile(t, root, "owned.html", "human")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "one.html")); err != nil {
+ t.Fatal(err)
+ }
+ writeTestFile(t, root, "one.md", "# Changed\n\n[broken](missing.md)\n")
+ if err := build(root); err == nil || !strings.Contains(err.Error(), "broken internal link") {
+ t.Fatalf("got %v", err)
+ }
+ raw, _ := os.ReadFile(filepath.Join(root, "one.html"))
+ if !strings.Contains(string(raw), "<title>One</title>") {
+ t.Fatalf("failed build changed output: %s", raw)
+ }
+ if err := os.Remove(filepath.Join(root, "one.md")); err != nil {
+ t.Fatal(err)
+ }
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "one.html")); !os.IsNotExist(err) {
+ t.Fatalf("stale output still exists: %v", err)
+ }
+ if raw, _ := os.ReadFile(filepath.Join(root, "owned.html")); string(raw) != "human" {
+ t.Fatalf("untracked HTML changed: %q", raw)
+ }
+
+ writeTestFile(t, root, "owned.md", "# Collision\n")
+ if err := build(root); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
+ t.Fatalf("got %v", err)
+ }
+}
+
+func TestLinkValidationRejectsTrackedOutputScheduledForCleanup(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", testLayouts())
+ writeTestFile(t, root, "one.md", "# One\n\n[Two](two.html)\n")
+ writeTestFile(t, root, "two.md", "# Two\n")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(root, "two.md")); err != nil {
+ t.Fatal(err)
+ }
+ if err := build(root); err == nil || !strings.Contains(err.Error(), "broken internal link") {
+ t.Fatalf("got %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "two.html")); err != nil {
+ t.Fatalf("failed build removed the old output: %v", err)
+ }
+}
+
+func TestManifestRejectsNonOutputPaths(t *testing.T) {
+ root := t.TempDir()
+ for _, output := range []string{"style.css", "../outside.html", `dir\outside.html`} {
+ raw, err := json.Marshal(manifest{Outputs: []string{output}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, manifestName), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := &site{root: root}
+ if _, err := s.readManifest(); err == nil || !strings.Contains(err.Error(), "unsafe path") {
+ t.Errorf("%q: got %v", output, err)
+ }
+ }
+}
+
+func TestSortingFilteringSlicing(t *testing.T) {
+ a := &Page{Title: "A", Meta: map[string]any{"post_date": "2024-01-01", "kind": "x"}, ModTime: time.Unix(1, 0)}
+ b := &Page{Title: "B", Meta: map[string]any{"post_date": "2025-01-01", "kind": "y"}, ModTime: time.Unix(2, 0)}
+ if got := sortPages([]*Page{a, b}, "post_date"); got[0] != b {
+ t.Fatal("date sort")
+ }
+ if got := filterPages([]*Page{a, b}, "kind", "x"); len(got) != 1 || got[0] != a {
+ t.Fatal("filter")
+ }
+ if got := slicePages([]*Page{a, b}, 0, 1); len(got) != 1 || got[0] != a {
+ t.Fatal("slice")
+ }
+}