package cms import ( "fmt" "io" "io/fs" "os" "path" "path/filepath" "sort" "strings" ) func Build(root string) (BuildReport, error) { project, err := LoadProject(root) if err != nil { return BuildReport{}, err } outDir := filepath.Join(root, "public") if err := os.RemoveAll(outDir); err != nil { return BuildReport{}, err } if err := os.MkdirAll(outDir, 0o755); err != nil { return BuildReport{}, err } report := BuildReport{OutputDir: outDir} if err := copyAssets(filepath.Join(root, "assets"), outDir, &report); err != nil { return BuildReport{}, err } hasIndex := false for _, doc := range project.Docs { if doc.Draft { continue } if doc.Type == "page" && doc.Slug == "index" { hasIndex = true } rel := outputRel(doc) rendered := renderDocument(project, doc) if err := writeFile(filepath.Join(outDir, filepath.FromSlash(rel)), []byte(rendered)); err != nil { return BuildReport{}, err } report.FilesWritten++ } if !hasIndex { rendered := renderGeneratedIndex(project) if err := writeFile(filepath.Join(outDir, "index.gmi"), []byte(rendered)); err != nil { return BuildReport{}, err } report.FilesWritten++ } for section, docs := range articlesBySection(project.Docs) { rendered := renderSectionIndex(project, section, docs) if err := writeFile(filepath.Join(outDir, section, "index.gmi"), []byte(rendered)); err != nil { return BuildReport{}, err } report.FilesWritten++ } return report, nil } func renderDocument(project Project, doc Document) string { body := expandBlocks(project, doc.Body) return strings.TrimRight(body, "\n") + "\n" } func renderGeneratedIndex(project Project) string { var b strings.Builder b.WriteString("# " + project.Config.Title + "\n\n") b.WriteString(renderMenu(project.Menus["top"])) b.WriteString("\n## Latest articles\n\n") b.WriteString(renderWidget(project, Widget{Name: "recent-articles", Type: "recent", Section: "articles", Limit: 5})) return strings.TrimRight(b.String(), "\n") + "\n" } func renderSectionIndex(project Project, section string, docs []Document) string { title := titleize(section) if cfg, ok := project.Sections[section]; ok { title = cfg.Title } var b strings.Builder b.WriteString("# " + title + "\n\n") if menu, ok := project.Menus["top"]; ok { b.WriteString(renderMenu(menu)) b.WriteString("\n") } for _, doc := range docs { b.WriteString("=> " + docURL(doc) + " " + doc.Date + " " + doc.Title + "\n") } return strings.TrimRight(b.String(), "\n") + "\n" } func expandBlocks(project Project, body string) string { var out []string for _, line := range strings.Split(body, "\n") { name, arg, ok := parseBlock(line) if !ok { out = append(out, line) continue } switch name { case "menu": out = append(out, strings.TrimRight(renderMenu(project.Menus[arg]), "\n")) case "widget": widget, ok := project.Widgets[arg] if !ok { out = append(out, "Missing widget: "+arg) continue } out = append(out, strings.TrimRight(renderWidget(project, widget), "\n")) default: out = append(out, line) } } return strings.Join(out, "\n") } func parseBlock(line string) (string, string, bool) { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "{{") || !strings.HasSuffix(line, "}}") { return "", "", false } line = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "{{"), "}}")) parts := strings.Fields(line) if len(parts) != 2 { return "", "", false } return parts[0], parts[1], true } func renderMenu(menu Menu) string { if len(menu.Items) == 0 { return "" } var b strings.Builder for _, item := range menu.Items { b.WriteString("=> " + item.URL + " " + item.Label + "\n") } return b.String() } func renderWidget(project Project, widget Widget) string { switch widget.Type { case "recent": return renderRecentWidget(project, widget) default: return "Unsupported widget: " + widget.Type + "\n" } } func renderRecentWidget(project Project, widget Widget) string { var docs []Document for _, doc := range project.Docs { if doc.Type == "article" && !doc.Draft && doc.Section == widget.Section { docs = append(docs, doc) } } sortArticles(docs) if widget.Limit > 0 && len(docs) > widget.Limit { docs = docs[:widget.Limit] } if len(docs) == 0 { return "No articles yet.\n" } var b strings.Builder for _, doc := range docs { b.WriteString("=> " + docURL(doc) + " " + doc.Date + " " + doc.Title + "\n") } return b.String() } func articlesBySection(docs []Document) map[string][]Document { sections := map[string][]Document{} for _, doc := range docs { if doc.Type != "article" || doc.Draft { continue } sections[doc.Section] = append(sections[doc.Section], doc) } for section := range sections { sortArticles(sections[section]) } return sections } func sortArticles(docs []Document) { sort.Slice(docs, func(i, j int) bool { if docs[i].Date != docs[j].Date { return docs[i].Date > docs[j].Date } return docs[i].Slug < docs[j].Slug }) } func outputRel(doc Document) string { if doc.Type == "page" { if doc.Slug == "index" || doc.Slug == "" { return "index.gmi" } return doc.Slug + ".gmi" } section := doc.Section if section == "" { section = "articles" } return path.Join(section, doc.Slug+".gmi") } func docURL(doc Document) string { rel := outputRel(doc) if rel == "index.gmi" { return "/" } return "/" + rel } func copyAssets(src, outDir string, report *BuildReport) error { if _, err := os.Stat(src); err != nil { if os.IsNotExist(err) { return nil } return err } return filepath.WalkDir(src, func(pathName string, entry fs.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() { return nil } rel, err := filepath.Rel(src, pathName) if err != nil { return err } dest := filepath.Join(outDir, filepath.FromSlash(filepath.ToSlash(rel))) if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return err } if err := copyFile(pathName, dest); err != nil { return err } report.FilesWritten++ return nil }) } func copyFile(src, dest string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dest) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } if err := out.Close(); err != nil { return err } return nil } func debugProject(project Project) string { return fmt.Sprintf("%d docs, %d menus, %d widgets", len(project.Docs), len(project.Menus), len(project.Widgets)) }