diff options
Diffstat (limited to 'internal/cms/build.go')
| -rw-r--r-- | internal/cms/build.go | 274 |
1 files changed, 274 insertions, 0 deletions
diff --git a/internal/cms/build.go b/internal/cms/build.go new file mode 100644 index 0000000..c434611 --- /dev/null +++ b/internal/cms/build.go @@ -0,0 +1,274 @@ +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)) +} |
