summaryrefslogtreecommitdiffstats
path: root/internal/cms/build.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cms/build.go')
-rw-r--r--internal/cms/build.go274
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))
+}