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