package main import ( "bufio" "bytes" "encoding/json" "encoding/xml" "errors" "flag" "fmt" stdhtml "html" "html/template" "io" "io/fs" "net/url" "os" "path" "path/filepath" "slices" "sort" "strings" "syscall" "time" chromahtml "github.com/alecthomas/chroma/v3/formatters/html" "github.com/alecthomas/chroma/v3/lexers" "github.com/alecthomas/chroma/v3/styles" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" extast "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" gmhtml "github.com/yuin/goldmark/renderer/html" gmtext "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" "golang.org/x/net/html" "gopkg.in/yaml.v3" ) const manifestName = ".weft-generated.json" const ( gitignoreStart = "# BEGIN weft generated outputs" gitignoreEnd = "# END weft generated outputs" ) type Page struct { SourcePath string OutputPath string URL string CanonicalURL string Title string Meta map[string]any ModTime time.Time Sections []template.HTML markdownSource []byte markdownAST ast.Node } type templateData struct { *Page Pages map[string][]*Page } type source struct { path string output string kind string page *Page } type site struct { root string canonicalRoot string pages map[string][]*Page sources []source templates *template.Template fallback string warnings []string markdown goldmark.Markdown } type manifest struct { Outputs []string `json:"outputs"` } func main() { flags := flag.NewFlagSet("weft", flag.ContinueOnError) fallback := flags.String("fallback", "page", "fallback template for Markdown pages") manageGitignore := flags.Bool("gitignore", false, "manage generated outputs in the site .gitignore") flags.Usage = func() { fmt.Fprintln(flags.Output(), "usage: weft [-gitignore] [-fallback template] ") } if err := flags.Parse(os.Args[1:]); err != nil { os.Exit(2) } if flags.NArg() != 2 { flags.Usage() os.Exit(2) } if err := buildWithFallback(flags.Arg(0), flags.Arg(1), *fallback, *manageGitignore); err != nil { fmt.Fprintln(os.Stderr, "weft:", err) os.Exit(1) } } func buildWithFallback(root, canonicalRoot, fallback string, manageGitignore bool) error { if strings.TrimSpace(fallback) == "" { return errors.New("fallback template cannot be empty") } 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, canonicalRoot: canonicalRoot, fallback: fallback, pages: map[string][]*Page{}, markdown: newMarkdown(), } if err := s.discover(); err != nil { return err } if err := s.loadTemplates(); err != nil { return err } if err := s.prepareMarkdown(); 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, manageGitignore); 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, "_") || strings.HasPrefix(part, ".") { return true } } return false } func (s *site) discover() error { outputs := map[string]string{} var templatePaths []string err := walkFiles(s.root, func(rel string, info fs.FileInfo) error { 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 walkFiles(root string, visit func(string, fs.FileInfo) error) error { active := map[string]bool{} var walk func(string, string) error walk = func(full, relDir string) error { real, err := filepath.EvalSymlinks(full) if err != nil { return err } if active[real] { return fmt.Errorf("symlink cycle at %s", filepath.ToSlash(relDir)) } active[real] = true defer delete(active, real) entries, err := os.ReadDir(full) if err != nil { return err } for _, entry := range entries { rel := filepath.Join(relDir, entry.Name()) if excluded(rel) { continue } name := filepath.Join(full, entry.Name()) info, err := os.Stat(name) if err != nil { return err } if info.IsDir() { if err := walk(name, rel); err != nil { return err } continue } if err := visit(filepath.ToSlash(rel), info); err != nil { return err } } return nil } return walk(root, "") } 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 } if s.markdown == nil { s.markdown = newMarkdown() } doc := s.markdown.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)) } } urlPath := "/" + out if path.Base(out) == "index.html" { urlPath = "/" + strings.TrimSuffix(out, "index.html") } return &Page{ SourcePath: rel, OutputPath: out, URL: urlPath, CanonicalURL: s.canonicalRoot + urlPath, Title: title, Meta: meta, ModTime: info.ModTime(), markdownSource: body, markdownAST: doc, }, warnings, nil } func newMarkdown() goldmark.Markdown { return goldmark.New( goldmark.WithExtensions(extension.GFM), goldmark.WithParserOptions(parser.WithAutoHeadingID()), ) } 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 } return strings.TrimSpace(string(heading.Text(source))) } 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; node = node.NextSibling() { var out bytes.Buffer if err := md.Renderer().Render(&out, source, node); err != nil { return nil, err } sections = append(sections, template.HTML(out.String())) // Goldmark raw HTML is disabled. } 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) } } for _, tmpl := range t.Templates() { if strings.HasPrefix(tmpl.Name(), "markdown/") && !supportedMarkdownTemplates[tmpl.Name()] { return fmt.Errorf("unsupported Markdown template %q", tmpl.Name()) } } s.templates = t return nil } func (s *site) prepareMarkdown() error { r := &markdownRenderer{templates: s.templates} r.renderer = s.markdown.Renderer() s.markdown.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(r, 0))) for _, src := range s.sources { if src.kind != "markdown" { continue } r.sourcePath = src.path sections, err := renderSections(s.markdown, src.page.markdownAST, src.page.markdownSource) if err != nil { return err } src.page.Sections = sections } return nil } var supportedMarkdownTemplates = map[string]bool{ "markdown/heading1": true, "markdown/heading2": true, "markdown/heading3": true, "markdown/heading4": true, "markdown/heading5": true, "markdown/heading6": true, "markdown/paragraph": true, "markdown/blockquote": true, "markdown/code_block": true, "markdown/fenced_code_block": true, "markdown/unordered_list": true, "markdown/ordered_list": true, "markdown/list_item": true, "markdown/thematic_break": true, "markdown/emphasis": true, "markdown/strong": true, "markdown/code_span": true, "markdown/link": true, "markdown/autolink": true, "markdown/email_autolink": true, "markdown/image": true, "markdown/hard_break": true, "markdown/strikethrough": true, "markdown/task_checkbox": true, "markdown/table": true, "markdown/table_header": true, "markdown/table_body": true, "markdown/table_row": true, "markdown/table_header_cell": true, "markdown/table_cell": true, } var tableMarkdownTemplates = []string{ "markdown/table", "markdown/table_header", "markdown/table_body", "markdown/table_row", "markdown/table_header_cell", "markdown/table_cell", } type markdownContent struct{ Content template.HTML } type markdownHeading struct { Content template.HTML ID string } type markdownFencedCode struct { Content template.HTML HighlightedContent template.HTML Language string } type markdownList struct { Content template.HTML Tight bool } type markdownOrderedList struct { Content template.HTML Start int Tight bool } type markdownLink struct { Content template.HTML Destination string Title string } type markdownAutoLink struct { Content template.HTML Destination string } type markdownImage struct { Alt string Destination string Title string } type markdownTask struct{ Checked bool } type markdownCell struct { Content template.HTML Alignment string } type markdownRenderer struct { templates *template.Template renderer renderer.Renderer sourcePath string } func (r *markdownRenderer) has(name string) bool { return r.templates.Lookup(name) != nil } func (r *markdownRenderer) hasAny(names ...string) bool { for _, name := range names { if r.has(name) { return true } } return false } func (r *markdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { if r.hasAny("markdown/heading1", "markdown/heading2", "markdown/heading3", "markdown/heading4", "markdown/heading5", "markdown/heading6") { reg.Register(ast.KindHeading, r.renderHeading) } if r.has("markdown/paragraph") { reg.Register(ast.KindParagraph, r.renderContainer("markdown/paragraph")) } if r.has("markdown/blockquote") { reg.Register(ast.KindBlockquote, r.renderContainer("markdown/blockquote")) } if r.has("markdown/code_block") { reg.Register(ast.KindCodeBlock, r.renderCodeBlock) } if r.has("markdown/fenced_code_block") { reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) } if r.hasAny("markdown/unordered_list", "markdown/ordered_list") { reg.Register(ast.KindList, r.renderList) } if r.has("markdown/list_item") { reg.Register(ast.KindListItem, r.renderContainer("markdown/list_item")) } if r.has("markdown/thematic_break") { reg.Register(ast.KindThematicBreak, r.renderLeaf("markdown/thematic_break", struct{}{})) } if r.hasAny("markdown/emphasis", "markdown/strong") { reg.Register(ast.KindEmphasis, r.renderEmphasis) } if r.has("markdown/code_span") { reg.Register(ast.KindCodeSpan, r.renderCodeSpan) } if r.has("markdown/link") { reg.Register(ast.KindLink, r.renderLink) } if r.hasAny("markdown/autolink", "markdown/email_autolink") { reg.Register(ast.KindAutoLink, r.renderAutoLink) } if r.has("markdown/image") { reg.Register(ast.KindImage, r.renderImage) } if r.has("markdown/hard_break") { reg.Register(ast.KindText, r.renderText) } if r.has("markdown/strikethrough") { reg.Register(extast.KindStrikethrough, r.renderContainer("markdown/strikethrough")) } if r.has("markdown/task_checkbox") { reg.Register(extast.KindTaskCheckBox, r.renderTaskCheckbox) } if r.hasAny(tableMarkdownTemplates...) { reg.Register(extast.KindTable, r.renderTable) } } func (r *markdownRenderer) children(source []byte, node ast.Node) (template.HTML, error) { var out bytes.Buffer for child := node.FirstChild(); child != nil; child = child.NextSibling() { if err := r.renderer.Render(&out, source, child); err != nil { return "", err } } return template.HTML(out.String()), nil } func (r *markdownRenderer) execute(name string, data any) ([]byte, error) { var out bytes.Buffer if err := r.templates.ExecuteTemplate(&out, name, data); err != nil { return nil, fmt.Errorf("render %s %s: %w", r.sourcePath, name, err) } return out.Bytes(), nil } func (r *markdownRenderer) writeTemplate(w util.BufWriter, name string, data any) (ast.WalkStatus, error) { out, err := r.execute(name, data) if err != nil { return ast.WalkStop, err } _, _ = w.Write(out) return ast.WalkSkipChildren, nil } func (r *markdownRenderer) renderContainer(name string) renderer.NodeRendererFunc { return func(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } content, err := r.children(source, node) if err != nil { return ast.WalkStop, err } return r.writeTemplate(w, name, markdownContent{Content: content}) } } func (r *markdownRenderer) renderLeaf(name string, data any) renderer.NodeRendererFunc { return func(w util.BufWriter, _ []byte, _ ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } return r.writeTemplate(w, name, data) } } func (r *markdownRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } heading := node.(*ast.Heading) content, err := r.children(source, node) if err != nil { return ast.WalkStop, err } var id string if value, ok := heading.AttributeString("id"); ok { switch value := value.(type) { case []byte: id = string(value) case string: id = value } } name := fmt.Sprintf("markdown/heading%d", heading.Level) if r.has(name) { return r.writeTemplate(w, name, markdownHeading{Content: content, ID: id}) } _, _ = fmt.Fprintf(w, "%s\n", content, heading.Level) return ast.WalkSkipChildren, nil } func rawLines(source []byte, node ast.Node) []byte { var out bytes.Buffer for i := 0; i < node.Lines().Len(); i++ { line := node.Lines().At(i) _, _ = out.Write(line.Value(source)) } return out.Bytes() } func escapedMarkdown(source []byte) template.HTML { return template.HTML(util.EscapeHTML(source)) } func (r *markdownRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } return r.writeTemplate(w, "markdown/code_block", markdownContent{Content: escapedMarkdown(rawLines(source, node))}) } func highlightedCode(language string, source []byte) template.HTML { if language == "" { return "" } known := false for _, name := range lexers.Names(true) { if strings.EqualFold(name, language) { known = true break } } if !known { return "" } lexer := lexers.Get(language) if lexer == nil { return "" } tokens, err := lexer.Tokenise(nil, string(source)) if err != nil { return "" } formatter := chromahtml.New( chromahtml.WithClasses(true), chromahtml.ClassPrefix("chroma-"), chromahtml.PreventSurroundingPre(true), ) var out bytes.Buffer if err := formatter.Format(&out, styles.Fallback, tokens); err != nil { return "" } return template.HTML(out.String()) } func (r *markdownRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } block := node.(*ast.FencedCodeBlock) code := rawLines(source, node) language := string(block.Language(source)) return r.writeTemplate(w, "markdown/fenced_code_block", markdownFencedCode{ Content: escapedMarkdown(code), HighlightedContent: highlightedCode(language, code), Language: language, }) } func (r *markdownRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } list := node.(*ast.List) content, err := r.children(source, node) if err != nil { return ast.WalkStop, err } name := "markdown/unordered_list" if list.IsOrdered() { name = "markdown/ordered_list" } if r.has(name) { if list.IsOrdered() { return r.writeTemplate(w, name, markdownOrderedList{Content: content, Start: list.Start, Tight: list.IsTight}) } return r.writeTemplate(w, name, markdownList{Content: content, Tight: list.IsTight}) } tag := "ul" if list.IsOrdered() { tag = "ol" } _, _ = fmt.Fprintf(w, "<%s", tag) if list.IsOrdered() && list.Start != 1 { _, _ = fmt.Fprintf(w, ` start="%d"`, list.Start) } _, _ = fmt.Fprintf(w, ">\n%s\n", content, tag) return ast.WalkSkipChildren, nil } func (r *markdownRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } emphasis := node.(*ast.Emphasis) content, err := r.children(source, node) if err != nil { return ast.WalkStop, err } name, tag := "markdown/emphasis", "em" if emphasis.Level == 2 { name, tag = "markdown/strong", "strong" } if r.has(name) { return r.writeTemplate(w, name, markdownContent{Content: content}) } _, _ = fmt.Fprintf(w, "<%s>%s", tag, content, tag) return ast.WalkSkipChildren, nil } func (r *markdownRenderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } var code bytes.Buffer for child := node.FirstChild(); child != nil; child = child.NextSibling() { value := child.(*ast.Text).Segment.Value(source) if bytes.HasSuffix(value, []byte("\n")) { _, _ = code.Write(value[:len(value)-1]) _ = code.WriteByte(' ') } else { _, _ = code.Write(value) } } return r.writeTemplate(w, "markdown/code_span", markdownContent{Content: escapedMarkdown(code.Bytes())}) } func (r *markdownRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } link := node.(*ast.Link) content, err := r.children(source, node) if err != nil { return ast.WalkStop, err } return r.writeTemplate(w, "markdown/link", markdownLink{ Content: content, Destination: string(link.Destination), Title: string(link.Title), }) } func (r *markdownRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } link := node.(*ast.AutoLink) destination := string(link.URL(source)) name := "markdown/autolink" if link.AutoLinkType == ast.AutoLinkEmail { name = "markdown/email_autolink" if !strings.HasPrefix(strings.ToLower(destination), "mailto:") { destination = "mailto:" + destination } } content := escapedMarkdown(link.Label(source)) if r.has(name) { return r.writeTemplate(w, name, markdownAutoLink{Content: content, Destination: destination}) } _, _ = w.WriteString(`%s`, content) return ast.WalkSkipChildren, nil } func imageAlt(source []byte, node ast.Node) string { var out bytes.Buffer writer := bufio.NewWriter(&out) var walk func(ast.Node) walk = func(parent ast.Node) { for child := parent.FirstChild(); child != nil; child = child.NextSibling() { switch child := child.(type) { case *ast.Text: gmhtml.DefaultWriter.Write(writer, child.Value(source)) if child.SoftLineBreak() || child.HardLineBreak() { _ = writer.WriteByte('\n') } case *ast.String: if child.IsCode() { _, _ = writer.Write(child.Value) } else { gmhtml.DefaultWriter.Write(writer, child.Value) } default: walk(child) } } } walk(node) _ = writer.Flush() return stdhtml.UnescapeString(out.String()) } func (r *markdownRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } image := node.(*ast.Image) return r.writeTemplate(w, "markdown/image", markdownImage{ Alt: imageAlt(source, node), Destination: string(image.Destination), Title: string(image.Title), }) } func (r *markdownRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } text := node.(*ast.Text) if text.IsRaw() { gmhtml.DefaultWriter.RawWrite(w, text.Segment.Value(source)) } else { gmhtml.DefaultWriter.Write(w, text.Segment.Value(source)) } if text.HardLineBreak() { return r.writeTemplate(w, "markdown/hard_break", struct{}{}) } if text.SoftLineBreak() { _ = w.WriteByte('\n') } return ast.WalkContinue, nil } func (r *markdownRenderer) renderTaskCheckbox(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } checkbox := node.(*extast.TaskCheckBox) return r.writeTemplate(w, "markdown/task_checkbox", markdownTask{Checked: checkbox.IsChecked}) } func (r *markdownRenderer) tablePart(name string, fallback []byte, data any) ([]byte, error) { if !r.has(name) { return fallback, nil } return r.execute(name, data) } func (r *markdownRenderer) tableCell(source []byte, node *extast.TableCell, header bool) ([]byte, error) { content, err := r.children(source, node) if err != nil { return nil, err } name, tag := "markdown/table_cell", "td" if header { name, tag = "markdown/table_header_cell", "th" } if r.has(name) { return r.execute(name, markdownCell{Content: content, Alignment: node.Alignment.String()}) } var out bytes.Buffer _, _ = fmt.Fprintf(&out, "<%s", tag) if node.Alignment != extast.AlignNone { _, _ = fmt.Fprintf(&out, ` style="text-align:%s"`, node.Alignment.String()) } _, _ = fmt.Fprintf(&out, ">%s\n", content, tag) return out.Bytes(), nil } func (r *markdownRenderer) tableHeader(source []byte, header *extast.TableHeader) ([]byte, error) { var content bytes.Buffer for node := header.FirstChild(); node != nil; node = node.NextSibling() { cell, err := r.tableCell(source, node.(*extast.TableCell), true) if err != nil { return nil, err } _, _ = content.Write(cell) } fallback := []byte("\n\n" + content.String() + "\n\n") return r.tablePart("markdown/table_header", fallback, markdownContent{Content: template.HTML(content.String())}) } func (r *markdownRenderer) tableRow(source []byte, row *extast.TableRow) ([]byte, error) { var content bytes.Buffer for node := row.FirstChild(); node != nil; node = node.NextSibling() { cell, err := r.tableCell(source, node.(*extast.TableCell), false) if err != nil { return nil, err } _, _ = content.Write(cell) } fallback := []byte("\n" + content.String() + "\n") return r.tablePart("markdown/table_row", fallback, markdownContent{Content: template.HTML(content.String())}) } func (r *markdownRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } var content bytes.Buffer child := node.FirstChild() if header, ok := child.(*extast.TableHeader); ok { rendered, err := r.tableHeader(source, header) if err != nil { return ast.WalkStop, err } _, _ = content.Write(rendered) child = child.NextSibling() } if child != nil { var rows bytes.Buffer for ; child != nil; child = child.NextSibling() { rendered, err := r.tableRow(source, child.(*extast.TableRow)) if err != nil { return ast.WalkStop, err } _, _ = rows.Write(rendered) } body, err := r.tablePart("markdown/table_body", []byte("\n"+rows.String()+"\n"), markdownContent{Content: template.HTML(rows.String())}) if err != nil { return ast.WalkStop, err } _, _ = content.Write(body) } if r.has("markdown/table") { return r.writeTemplate(w, "markdown/table", markdownContent{Content: template.HTML(content.String())}) } _, _ = fmt.Fprintf(w, "\n%s
\n", content.String()) return ast.WalkSkipChildren, nil } func templateFuncs() template.FuncMap { return template.FuncMap{ "rel": relativePath, "splitPath": func(value string) []string { return strings.Split(value, "/") }, "joinPath": func(parts []string) string { return strings.Join(parts, "/") }, "dirPath": dirPath, "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 dirPath(value string) string { dir := path.Dir(value) if dir == "." { return "" } return dir } func relativePath(from, target string) string { if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "mailto:") || strings.HasPrefix(target, "#") { return target } directory := strings.HasSuffix(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 } rel = path.Clean(filepath.ToSlash(rel)) if directory { return strings.TrimSuffix(rel, "/") + "/" } return rel } func sortPages(pages []*Page, keys ...string) []*Page { result := slices.Clone(pages) date := func(page *Page) time.Time { for _, key := range keys { if key == "mod_time" { return page.ModTime } if value, ok := asTime(page.Meta[key]); ok { return value } } return time.Time{} } sort.SliceStable(result, func(i, j int) bool { if len(keys) == 1 && keys[0] == "title" { return strings.ToLower(result[i].Title) < strings.ToLower(result[j].Title) } a := date(result[i]) b := date(result[j]) 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 = s.markdownTemplate(src.path) } else { current = &Page{SourcePath: src.path, OutputPath: src.output, URL: "/" + src.output, CanonicalURL: s.canonicalRoot + 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) markdownTemplate(sourcePath string) string { for candidate := strings.TrimSuffix(sourcePath, ".md"); candidate != "." && candidate != ""; candidate = path.Dir(candidate) { if s.templates.Lookup(candidate) != nil { return candidate } } return s.fallback } 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, manageGitignore bool) 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 } if manageGitignore { contents, err := s.gitignore(paths) if err != nil { return err } if err := os.WriteFile(filepath.Join(stage, ".gitignore"), contents, 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) _ = moveFile(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 := moveFile(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 } } } install := append(slices.Clone(paths), manifestName) if manageGitignore { install = append(install, ".gitignore") } for _, rel := range install { to := filepath.Join(s.root, filepath.FromSlash(rel)) // Leave byte-identical outputs untouched so their mtimes (and thus // Last-Modified/ETag headers) only change when content does. if existing, err := os.ReadFile(to); err == nil { staged, err := os.ReadFile(filepath.Join(stage, filepath.FromSlash(rel))) if err != nil { rollback() return err } if bytes.Equal(existing, staged) { continue } } if err := moveOld(rel); err != nil { rollback() return err } if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { rollback() return err } if err := moveFile(filepath.Join(stage, filepath.FromSlash(rel)), to); err != nil { rollback() return err } installed = append(installed, rel) } return nil } // moveFile is os.Rename with a fallback for cross-device moves (EXDEV), which // happen when part of the site tree is a symlink onto another filesystem: the // contents are staged next to the destination and renamed into place there. func moveFile(from, to string) error { err := os.Rename(from, to) if err == nil || !errors.Is(err, syscall.EXDEV) { return err } contents, err := os.ReadFile(from) if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(to), ".weft-move-") if err != nil { return err } _, werr := tmp.Write(contents) if cerr := tmp.Close(); werr == nil { werr = cerr } if werr == nil { werr = os.Chmod(tmp.Name(), 0o644) } if werr == nil { werr = os.Rename(tmp.Name(), to) } if werr != nil { _ = os.Remove(tmp.Name()) return werr } return os.Remove(from) } func (s *site) gitignore(outputs []string) ([]byte, error) { raw, err := os.ReadFile(filepath.Join(s.root, ".gitignore")) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } var kept strings.Builder inBlock, seen := false, false for _, line := range strings.SplitAfter(string(raw), "\n") { value := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") switch value { case gitignoreStart: if inBlock || seen { return nil, errors.New("invalid managed block in .gitignore") } inBlock, seen = true, true case gitignoreEnd: if !inBlock { return nil, errors.New("invalid managed block in .gitignore") } inBlock = false default: if !inBlock { kept.WriteString(line) } } } if inBlock { return nil, errors.New("unterminated managed block in .gitignore") } if kept.Len() > 0 && !strings.HasSuffix(kept.String(), "\n") { kept.WriteByte('\n') } kept.WriteString(gitignoreStart + "\n") for _, output := range outputs { if strings.ContainsAny(output, "\r\n") { return nil, fmt.Errorf("cannot add output to .gitignore: %q", output) } symlinked, err := s.hasSymlinkedParent(output) if err != nil { return nil, err } if symlinked { continue } kept.WriteString("/" + escapeGitignore(output) + "\n") } kept.WriteString(gitignoreEnd + "\n") return []byte(kept.String()), nil } func (s *site) hasSymlinkedParent(output string) (bool, error) { current := s.root for _, part := range strings.Split(path.Dir(output), "/") { if part == "." { continue } current = filepath.Join(current, part) info, err := os.Lstat(current) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, err } if info.Mode()&os.ModeSymlink != 0 { return true, nil } } return false, nil } func escapeGitignore(value string) string { return strings.NewReplacer( `\`, `\\`, " ", `\ `, "#", `\#`, "!", `\!`, "[", `\[`, "]", `\]`, "*", `\*`, "?", `\?`, ).Replace(value) }