forked from
tangled.org/core
Monorepo for Tangled
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}