diff options
| author | t <t@tjp.lol> | 2026-07-11 12:00:44 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-11 12:00:44 -0600 |
| commit | 7485ba6b87993f8798aeeab2a1df6f1e9a815691 (patch) | |
| tree | 1f07894af5111c73a1f3fd63497e3cfdf896b73b | |
Build the Weft static site generator
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 48 | ||||
| -rw-r--r-- | go.mod | 9 | ||||
| -rw-r--r-- | go.sum | 8 | ||||
| -rw-r--r-- | main.go | 696 | ||||
| -rw-r--r-- | main_test.go | 268 |
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 +``` @@ -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 +) @@ -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= @@ -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 <strong>full</strong> 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") + } +} |
