Monorepo for Tangled
at master 220 lines 5.5 kB view raw
1package main 2 3import ( 4 "context" 5 "fmt" 6 "io" 7 "io/fs" 8 "log/slog" 9 "net/http" 10 "os" 11 "path/filepath" 12 13 "tangled.org/core/appview/config" 14 "tangled.org/core/appview/pages" 15 "tangled.org/core/blog" 16 "tangled.org/core/idresolver" 17 tlog "tangled.org/core/log" 18) 19 20const ( 21 postsDir = "blog/posts" 22 templatesDir = "blog/templates" 23) 24 25func main() { 26 if len(os.Args) < 2 { 27 fmt.Fprintln(os.Stderr, "usage: blog <build|serve> [flags]") 28 os.Exit(1) 29 } 30 31 ctx := context.Background() 32 logger := tlog.New("blog") 33 34 switch os.Args[1] { 35 case "build": 36 if err := runBuild(ctx, logger); err != nil { 37 logger.Error("build failed", "err", err) 38 os.Exit(1) 39 } 40 case "serve": 41 addr := "0.0.0.0:3001" 42 if len(os.Args) >= 3 { 43 addr = os.Args[2] 44 } 45 if err := runServe(ctx, logger, addr); err != nil { 46 logger.Error("serve failed", "err", err) 47 os.Exit(1) 48 } 49 default: 50 fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1]) 51 os.Exit(1) 52 } 53} 54 55func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { 56 resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) 57 return pages.NewPages(cfg, resolver, nil, logger), nil 58} 59 60func runBuild(ctx context.Context, logger *slog.Logger) error { 61 cfg, err := config.LoadConfig(ctx) 62 if err != nil { 63 return fmt.Errorf("failed to load config: %w", err) 64 } 65 66 p, err := makePages(ctx, cfg, logger) 67 if err != nil { 68 return fmt.Errorf("creating pages: %w", err) 69 } 70 71 posts, err := blog.Posts(postsDir) 72 if err != nil { 73 return fmt.Errorf("parsing posts: %w", err) 74 } 75 76 outDir := "build" 77 if err := os.MkdirAll(outDir, 0755); err != nil { 78 return err 79 } 80 81 // index 82 if err := renderToFile(outDir, "index.html", func(w io.Writer) error { 83 return blog.RenderIndex(p, templatesDir, posts, w) 84 }); err != nil { 85 return fmt.Errorf("rendering index: %w", err) 86 } 87 88 for _, post := range posts { 89 postDir := filepath.Join(outDir, post.Meta.Slug) 90 if err := os.MkdirAll(postDir, 0755); err != nil { 91 return err 92 } 93 if err := renderToFile(postDir, "index.html", func(w io.Writer) error { 94 return blog.RenderPost(p, templatesDir, post, w) 95 }); err != nil { 96 return fmt.Errorf("rendering post %s: %w", post.Meta.Slug, err) 97 } 98 } 99 100 // atom feed 101 baseURL := "https://blog.tangled.org" 102 atom, err := blog.AtomFeed(posts, baseURL) 103 if err != nil { 104 return fmt.Errorf("generating atom feed: %w", err) 105 } 106 if err := os.WriteFile(filepath.Join(outDir, "feed.xml"), []byte(atom), 0644); err != nil { 107 return fmt.Errorf("writing feed: %w", err) 108 } 109 110 // copy embedded static assets into build/static/ so Cloudflare Pages 111 // can serve them from the same origin as the built HTML 112 staticSrc, err := fs.Sub(pages.Files, "static") 113 if err != nil { 114 return fmt.Errorf("accessing embedded static dir: %w", err) 115 } 116 if err := copyFS(staticSrc, filepath.Join(outDir, "static")); err != nil { 117 return fmt.Errorf("copying static assets: %w", err) 118 } 119 120 logger.Info("build complete", "dir", outDir) 121 return nil 122} 123 124// copyFS copies all files from src into destDir, preserving directory structure. 125func copyFS(src fs.FS, destDir string) error { 126 return fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error { 127 if err != nil { 128 return err 129 } 130 dest := filepath.Join(destDir, path) 131 if d.IsDir() { 132 return os.MkdirAll(dest, 0755) 133 } 134 data, err := fs.ReadFile(src, path) 135 if err != nil { 136 return err 137 } 138 return os.WriteFile(dest, data, 0644) 139 }) 140} 141 142func runServe(ctx context.Context, logger *slog.Logger, addr string) error { 143 cfg, err := config.LoadConfig(ctx) 144 if err != nil { 145 return fmt.Errorf("failed to load config: %w", err) 146 } 147 148 p, err := makePages(ctx, cfg, logger) 149 if err != nil { 150 return fmt.Errorf("creating pages: %w", err) 151 } 152 153 mux := http.NewServeMux() 154 155 // index 156 mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 157 if r.URL.Path != "/" { 158 http.NotFound(w, r) 159 return 160 } 161 posts, err := blog.AllPosts(postsDir) 162 if err != nil { 163 http.Error(w, err.Error(), http.StatusInternalServerError) 164 return 165 } 166 if err := blog.RenderIndex(p, templatesDir, posts, w); err != nil { 167 logger.Error("render index", "err", err) 168 } 169 }) 170 171 // individual posts directly at /<slug> 172 mux.HandleFunc("GET /{slug}", func(w http.ResponseWriter, r *http.Request) { 173 slug := r.PathValue("slug") 174 posts, err := blog.AllPosts(postsDir) 175 if err != nil { 176 http.Error(w, err.Error(), http.StatusInternalServerError) 177 return 178 } 179 for _, post := range posts { 180 if post.Meta.Slug == slug { 181 if err := blog.RenderPost(p, templatesDir, post, w); err != nil { 182 logger.Error("render post", "err", err) 183 } 184 return 185 } 186 } 187 http.NotFound(w, r) 188 }) 189 190 // atom feed at /feed.xml 191 mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { 192 posts, err := blog.Posts(postsDir) 193 if err != nil { 194 http.Error(w, err.Error(), http.StatusInternalServerError) 195 return 196 } 197 atom, err := blog.AtomFeed(posts, "https://blog.tangled.org") 198 if err != nil { 199 http.Error(w, err.Error(), http.StatusInternalServerError) 200 return 201 } 202 w.Header().Set("Content-Type", "application/atom+xml") 203 fmt.Fprint(w, atom) 204 }) 205 206 // appview static files (tw.css, fonts, icons, logos) 207 mux.Handle("GET /static/", p.Static()) 208 209 logger.Info("serving", "addr", addr) 210 return http.ListenAndServe(addr, mux) 211} 212 213func renderToFile(dir, name string, fn func(io.Writer) error) error { 214 f, err := os.Create(filepath.Join(dir, name)) 215 if err != nil { 216 return err 217 } 218 defer f.Close() 219 return fn(f) 220}