repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
e2630f9
parent
87407ab
author
Eric Bower
date
2025-01-17 15:52:39 -0500 EST
chore(prose): experimental ssg works
6 files changed,  +411, -167
M cmd/scripts/prose-ssg/main.go
+105, -3
  1@@ -1,9 +1,19 @@
  2 package main
  3 
  4 import (
  5+	"bufio"
  6+	"context"
  7+	"encoding/json"
  8+	"log/slog"
  9+	"sync"
 10+	"time"
 11+
 12 	"github.com/picosh/pico/db/postgres"
 13+	"github.com/picosh/pico/filehandlers"
 14 	"github.com/picosh/pico/prose"
 15+	"github.com/picosh/pico/shared"
 16 	"github.com/picosh/pico/shared/storage"
 17+	"github.com/picosh/utils/pipe"
 18 )
 19 
 20 func bail(err error) {
 21@@ -12,11 +22,63 @@ func bail(err error) {
 22 	}
 23 }
 24 
 25+func render(ssg *prose.SSG, ch chan string) {
 26+	var pendingFlushes sync.Map
 27+	tick := time.Tick(10 * time.Second)
 28+	for {
 29+		select {
 30+		case userID := <-ch:
 31+			ssg.Logger.Info("received request to generate blog", "userId", userID)
 32+			pendingFlushes.Store(userID, userID)
 33+		case <-tick:
 34+			ssg.Logger.Info("flushing ssg requests")
 35+			go func() {
 36+				pendingFlushes.Range(func(key, value any) bool {
 37+					pendingFlushes.Delete(key)
 38+					user, err := ssg.DB.FindUser(value.(string))
 39+					if err != nil {
 40+						ssg.Logger.Error("cannot find user", "err", err)
 41+						return true
 42+					}
 43+
 44+					bucket, err := ssg.Storage.GetBucket(shared.GetAssetBucketName(user.ID))
 45+					if err != nil {
 46+						ssg.Logger.Error("cannot find bucket", "err", err)
 47+						return true
 48+					}
 49+
 50+					err = ssg.ProseBlog(user, bucket)
 51+					if err != nil {
 52+						ssg.Logger.Error("cannot generate blog", "err", err)
 53+					}
 54+					return true
 55+				})
 56+			}()
 57+		}
 58+	}
 59+}
 60+
 61+func createSubProseDrain(ctx context.Context, logger *slog.Logger) *pipe.ReconnectReadWriteCloser {
 62+	info := shared.NewPicoPipeClient()
 63+	send := pipe.NewReconnectReadWriteCloser(
 64+		ctx,
 65+		logger,
 66+		info,
 67+		"sub to prose-drain",
 68+		"sub prose-drain -k",
 69+		100,
 70+		-1,
 71+	)
 72+	return send
 73+}
 74+
 75 func main() {
 76 	cfg := prose.NewConfigSite()
 77-	picoDb := postgres.NewDB(cfg.DbURL, cfg.Logger)
 78-	st, err := storage.NewStorageFS(cfg.Logger, cfg.StorageDir)
 79+	logger := cfg.Logger
 80+	picoDb := postgres.NewDB(cfg.DbURL, logger)
 81+	st, err := storage.NewStorageMinio(logger, cfg.MinioURL, cfg.MinioUser, cfg.MinioPass)
 82 	bail(err)
 83+
 84 	ssg := &prose.SSG{
 85 		Cfg:       cfg,
 86 		DB:        picoDb,
 87@@ -25,5 +87,45 @@ func main() {
 88 		TmplDir:   "./prose/html",
 89 		StaticDir: "./prose/public",
 90 	}
 91-	bail(ssg.Prose())
 92+
 93+	ctx := context.Background()
 94+	drain := createSubProseDrain(ctx, cfg.Logger)
 95+
 96+	ch := make(chan string)
 97+	go render(ssg, ch)
 98+
 99+	for {
100+		scanner := bufio.NewScanner(drain)
101+		for scanner.Scan() {
102+			var data filehandlers.SuccesHook
103+
104+			err := json.Unmarshal(scanner.Bytes(), &data)
105+			if err != nil {
106+				logger.Error("json unmarshal", "err", err)
107+				continue
108+			}
109+
110+			logger = logger.With(
111+				"userId", data.UserID,
112+				"filename", data.Filename,
113+				"action", data.Action,
114+			)
115+
116+			if data.Action == "delete" {
117+				bucket, err := ssg.Storage.GetBucket(shared.GetAssetBucketName(data.UserID))
118+				if err != nil {
119+					ssg.Logger.Error("cannot find bucket", "err", err)
120+					continue
121+				}
122+				err = st.DeleteObject(bucket, data.Filename)
123+				if err != nil {
124+					logger.Error("cannot delete object", "err", err)
125+					continue
126+				}
127+				ch <- data.UserID
128+			} else if data.Action == "create" || data.Action == "update" {
129+				ch <- data.UserID
130+			}
131+		}
132+	}
133 }
M db/db.go
+3, -0
 1@@ -131,6 +131,9 @@ type Post struct {
 2 	MimeType    string     `json:"mime_type"`
 3 	Data        PostData   `json:"data"`
 4 	Tags        []string   `json:"tags"`
 5+
 6+	// computed
 7+	IsVirtual bool
 8 }
 9 
10 type Paginate[T any] struct {
M pgs/web.go
+2, -0
 1@@ -397,6 +397,7 @@ var imgRegex = regexp.MustCompile("(.+.(?:jpg|jpeg|png|gif|webp|svg))(/.+)")
 2 func (web *WebRouter) AssetRequest(w http.ResponseWriter, r *http.Request) {
 3 	fname := r.PathValue("fname")
 4 	if imgRegex.MatchString(fname) {
 5+		fmt.Println("HIT")
 6 		web.ImageRequest(w, r)
 7 		return
 8 	}
 9@@ -414,6 +415,7 @@ func (web *WebRouter) ImageRequest(w http.ResponseWriter, r *http.Request) {
10 	if len(matches) >= 3 {
11 		imgOpts = matches[2]
12 	}
13+	fmt.Println("ZZZ", fname, imgOpts)
14 
15 	opts, err := storage.UriToImgProcessOpts(imgOpts)
16 	if err != nil {
M prose/html/blog-aside.partial.tmpl
+1, -1
1@@ -22,7 +22,7 @@
2         </div>
3 
4         {{if .HasFilter}}
5-            <a href={{.URL}}>clear filters</a>
6+            <a href="/">clear filters</a>
7         {{end}}
8 
9         <div class="posts group mt-2">
M prose/ssg.go
+298, -162
  1@@ -53,22 +53,7 @@ func (ssg *SSG) tmpl(fpath string) string {
  2 	return filepath.Join(ssg.TmplDir, fpath)
  3 }
  4 
  5-func (ssg *SSG) blogPage(w io.Writer, user *db.User, tag string) error {
  6-	pager := &db.Pager{Num: 250, Page: 0}
  7-	var err error
  8-	var posts []*db.Post
  9-	var p *db.Paginate[*db.Post]
 10-	if tag == "" {
 11-		p, err = ssg.DB.FindPostsForUser(pager, user.ID, Space)
 12-	} else {
 13-		p, err = ssg.DB.FindUserPostsByTag(pager, tag, user.ID, Space)
 14-	}
 15-	posts = p.Data
 16-
 17-	if err != nil {
 18-		return err
 19-	}
 20-
 21+func (ssg *SSG) blogPage(w io.Writer, user *db.User, blog *UserBlogData, tag string) error {
 22 	files := []string{
 23 		ssg.tmpl("blog.page.tmpl"),
 24 		ssg.tmpl("blog-default.partial.tmpl"),
 25@@ -91,9 +76,8 @@ func (ssg *SSG) blogPage(w io.Writer, user *db.User, tag string) error {
 26 		Domain:     getBlogDomain(user.Name, ssg.Cfg.Domain),
 27 	}
 28 	readmeTxt := &ReadmeTxt{}
 29-
 30-	readme, err := ssg.DB.FindPostWithFilename("_readme.md", user.ID, Space)
 31-	if err == nil {
 32+	readme := blog.Readme
 33+	if readme != nil {
 34 		parsedText, err := shared.ParseText(readme.Text)
 35 		if err != nil {
 36 			return err
 37@@ -126,17 +110,23 @@ func (ssg *SSG) blogPage(w io.Writer, user *db.User, tag string) error {
 38 		}
 39 	}
 40 
 41-	hasCSS := false
 42-	_, err = ssg.DB.FindPostWithFilename("_styles.css", user.ID, Space)
 43-	if err == nil {
 44-		hasCSS = true
 45-	}
 46+	hasCSS := blog.CSS != nil
 47+	postCollection := []PostItemData{}
 48+	for _, post := range blog.Posts {
 49+		if tag != "" {
 50+			parsed, err := shared.ParseText(post.Text)
 51+			if err != nil {
 52+				blog.Logger.Error("post parse text", "err", err)
 53+				continue
 54+			}
 55+			if !slices.Contains(parsed.Tags, tag) {
 56+				continue
 57+			}
 58+		}
 59 
 60-	postCollection := make([]PostItemData, 0, len(posts))
 61-	for _, post := range posts {
 62 		p := PostItemData{
 63 			URL: template.URL(
 64-				fmt.Sprintf("/%s.html", post.Slug),
 65+				fmt.Sprintf("/%s", post.Slug),
 66 			),
 67 			BlogURL:        template.URL("/"),
 68 			Title:          utils.FilenameToTitle(post.Filename, post.Title),
 69@@ -167,23 +157,7 @@ func (ssg *SSG) blogPage(w io.Writer, user *db.User, tag string) error {
 70 	return ts.Execute(w, data)
 71 }
 72 
 73-func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
 74-	var err error
 75-	pager := &db.Pager{Num: 10, Page: 0}
 76-	var posts []*db.Post
 77-	var p *db.Paginate[*db.Post]
 78-	if tag == "" {
 79-		p, err = ssg.DB.FindPostsForUser(pager, user.ID, Space)
 80-	} else {
 81-		p, err = ssg.DB.FindUserPostsByTag(pager, tag, user.ID, Space)
 82-	}
 83-
 84-	if err != nil {
 85-		return err
 86-	}
 87-
 88-	posts = p.Data
 89-
 90+func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, blog *UserBlogData) error {
 91 	ts, err := template.ParseFiles(ssg.tmpl("rss.page.tmpl"))
 92 	if err != nil {
 93 		return err
 94@@ -194,8 +168,8 @@ func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
 95 		Domain: getBlogDomain(user.Name, ssg.Cfg.Domain),
 96 	}
 97 
 98-	readme, err := ssg.DB.FindPostWithFilename("_readme.md", user.ID, Space)
 99-	if err == nil {
100+	readme := blog.Readme
101+	if readme != nil {
102 		parsedText, err := shared.ParseText(readme.Text)
103 		if err != nil {
104 			return err
105@@ -225,7 +199,7 @@ func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
106 	}
107 
108 	var feedItems []*feeds.Item
109-	for _, post := range posts {
110+	for _, post := range blog.Posts {
111 		if slices.Contains(ssg.Cfg.HiddenPosts, post.Filename) {
112 			continue
113 		}
114@@ -234,9 +208,9 @@ func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
115 			return err
116 		}
117 
118-		footer, err := ssg.DB.FindPostWithFilename("_footer.md", user.ID, Space)
119+		footer := blog.Footer
120 		var footerHTML string
121-		if err == nil {
122+		if footer != nil {
123 			footerParsed, err := shared.ParseText(footer.Text)
124 			if err != nil {
125 				return err
126@@ -261,7 +235,7 @@ func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
127 			Link:        &feeds.Link{Href: realUrl},
128 			Content:     tpl.String(),
129 			Updated:     *post.UpdatedAt,
130-			Created:     *post.CreatedAt,
131+			Created:     *post.PublishAt,
132 			Description: post.Description,
133 		}
134 
135@@ -282,40 +256,31 @@ func (ssg *SSG) rssBlogPage(w io.Writer, user *db.User, tag string) error {
136 	return err
137 }
138 
139-func (ssg *SSG) postPage(w io.Writer, user *db.User, post *db.Post) ([]string, error) {
140+func (ssg *SSG) writePostPage(w io.Writer, user *db.User, post *db.Post, blog *UserBlogData) (*shared.ParsedText, error) {
141 	blogName := getBlogName(user.Name)
142 	favicon := ""
143 	ogImage := ""
144 	ogImageCard := ""
145-	hasCSS := false
146 	withStyles := true
147 	domain := getBlogDomain(user.Name, ssg.Cfg.Domain)
148 	var data PostPageData
149-	aliases := []string{}
150 
151-	css, err := ssg.DB.FindPostWithFilename("_styles.css", user.ID, Space)
152-	if err == nil {
153-		if len(css.Text) > 0 {
154-			hasCSS = true
155-		}
156-	}
157-
158-	footer, err := ssg.DB.FindPostWithFilename("_footer.md", user.ID, Space)
159+	footer := blog.Footer
160 	var footerHTML template.HTML
161-	if err == nil {
162+	if footer != nil {
163 		footerParsed, err := shared.ParseText(footer.Text)
164 		if err != nil {
165-			return aliases, err
166+			return nil, err
167 		}
168 		footerHTML = template.HTML(footerParsed.Html)
169 	}
170 
171 	// we need the blog name from the readme unfortunately
172-	readme, err := ssg.DB.FindPostWithFilename("_readme.md", user.ID, Space)
173-	if err == nil {
174+	readme := blog.Readme
175+	if readme != nil {
176 		readmeParsed, err := shared.ParseText(readme.Text)
177 		if err != nil {
178-			return aliases, err
179+			return nil, err
180 		}
181 		if readmeParsed.MetaData.Title != "" {
182 			blogName = readmeParsed.MetaData.Title
183@@ -332,7 +297,7 @@ func (ssg *SSG) postPage(w io.Writer, user *db.User, post *db.Post) ([]string, e
184 	diff := ""
185 	parsedText, err := shared.ParseText(post.Text)
186 	if err != nil {
187-		return aliases, err
188+		return nil, err
189 	}
190 
191 	if parsedText.Image != "" {
192@@ -343,8 +308,6 @@ func (ssg *SSG) postPage(w io.Writer, user *db.User, post *db.Post) ([]string, e
193 		ogImageCard = parsedText.ImageCard
194 	}
195 
196-	aliases = parsedText.Aliases
197-
198 	unlisted := false
199 	if post.Hidden || post.PublishAt.After(time.Now()) {
200 		unlisted = true
201@@ -365,7 +328,7 @@ func (ssg *SSG) postPage(w io.Writer, user *db.User, post *db.Post) ([]string, e
202 		Username:     user.Name,
203 		BlogName:     blogName,
204 		Contents:     template.HTML(parsedText.Html),
205-		HasCSS:       hasCSS,
206+		HasCSS:       blog.CSS != nil,
207 		CssURL:       template.URL("/_styles.css"),
208 		Tags:         parsedText.Tags,
209 		Image:        template.URL(ogImage),
210@@ -385,10 +348,10 @@ func (ssg *SSG) postPage(w io.Writer, user *db.User, post *db.Post) ([]string, e
211 	}
212 	ts, err := template.ParseFiles(files...)
213 	if err != nil {
214-		return aliases, err
215+		return nil, err
216 	}
217 
218-	return aliases, ts.Execute(w, data)
219+	return parsedText, ts.Execute(w, data)
220 }
221 
222 func (ssg *SSG) discoverPage(w io.Writer) error {
223@@ -511,9 +474,9 @@ func (ssg *SSG) discoverRssPage(w io.Writer) error {
224 	return err
225 }
226 
227-func (ssg *SSG) upload(bucket sst.Bucket, fpath string, rdr io.Reader) error {
228-	toSite := filepath.Join("prose-blog", fpath)
229-	ssg.Logger.Info("uploading object", "bucket", bucket.Name, "object", toSite)
230+func (ssg *SSG) upload(logger *slog.Logger, bucket sst.Bucket, fpath string, rdr io.Reader) error {
231+	toSite := filepath.Join("prose", fpath)
232+	logger.Info("uploading object", "bucket", bucket.Name, "object", toSite)
233 	buf := &bytes.Buffer{}
234 	size, err := io.Copy(buf, rdr)
235 	if err != nil {
236@@ -527,25 +490,18 @@ func (ssg *SSG) upload(bucket sst.Bucket, fpath string, rdr io.Reader) error {
237 	return err
238 }
239 
240-func (ssg *SSG) notFoundPage(w io.Writer, user *db.User) error {
241+func (ssg *SSG) notFoundPage(w io.Writer, user *db.User, blog *UserBlogData) error {
242 	ogImage := ""
243 	ogImageCard := ""
244 	favicon := ""
245 	contents := template.HTML("Oops!  we can't seem to find this post.")
246 	title := "Post not found"
247 	desc := "Post not found"
248-	hasCSS := false
249-
250-	css, err := ssg.DB.FindPostWithFilename("_styles.css", user.ID, Space)
251-	if err == nil {
252-		if len(css.Text) > 0 {
253-			hasCSS = true
254-		}
255-	}
256+	hasCSS := blog.CSS != nil
257 
258-	footer, err := ssg.DB.FindPostWithFilename("_footer.md", user.ID, Space)
259+	footer := blog.Footer
260 	var footerHTML template.HTML
261-	if err == nil {
262+	if footer != nil {
263 		footerParsed, err := shared.ParseText(footer.Text)
264 		if err != nil {
265 			return err
266@@ -554,8 +510,8 @@ func (ssg *SSG) notFoundPage(w io.Writer, user *db.User) error {
267 	}
268 
269 	// we need the blog name from the readme unfortunately
270-	readme, err := ssg.DB.FindPostWithFilename("_readme.md", user.ID, Space)
271-	if err == nil {
272+	readme := blog.Readme
273+	if readme != nil {
274 		readmeParsed, err := shared.ParseText(readme.Text)
275 		if err != nil {
276 			return err
277@@ -565,11 +521,11 @@ func (ssg *SSG) notFoundPage(w io.Writer, user *db.User) error {
278 		favicon = readmeParsed.Favicon
279 	}
280 
281-	notFound, err := ssg.DB.FindPostWithFilename("_404.md", user.ID, Space)
282-	if err == nil {
283+	notFound := blog.NotFound
284+	if notFound != nil {
285 		notFoundParsed, err := shared.ParseText(notFound.Text)
286 		if err != nil {
287-			ssg.Logger.Error("could not parse markdown", "err", err.Error())
288+			blog.Logger.Error("could not parse markdown", "err", err.Error())
289 			return err
290 		}
291 		if notFoundParsed.MetaData.Title != "" {
292@@ -616,10 +572,10 @@ func (ssg *SSG) notFoundPage(w io.Writer, user *db.User) error {
293 	return ts.Execute(w, data)
294 }
295 
296-func (ssg *SSG) images(user *db.User, bucket sst.Bucket) error {
297+func (ssg *SSG) images(user *db.User, blog *UserBlogData) error {
298 	imgBucket, err := ssg.Storage.GetBucket(shared.GetImgsBucketName(user.ID))
299 	if err != nil {
300-		ssg.Logger.Info("user does not have an images dir, skipping")
301+		blog.Logger.Info("user does not have an images dir, skipping")
302 		return nil
303 	}
304 	imgs, err := ssg.Storage.ListObjects(imgBucket, "/", false)
305@@ -632,7 +588,7 @@ func (ssg *SSG) images(user *db.User, bucket sst.Bucket) error {
306 		if err != nil {
307 			return err
308 		}
309-		err = ssg.upload(bucket, inf.Name(), rdr)
310+		err = ssg.upload(blog.Logger, blog.Bucket, inf.Name(), rdr)
311 		if err != nil {
312 			return err
313 		}
314@@ -641,7 +597,7 @@ func (ssg *SSG) images(user *db.User, bucket sst.Bucket) error {
315 	return nil
316 }
317 
318-func (ssg *SSG) static(bucket sst.Bucket) error {
319+func (ssg *SSG) static(logger *slog.Logger, bucket sst.Bucket) error {
320 	files, err := os.ReadDir(ssg.StaticDir)
321 	if err != nil {
322 		return err
323@@ -655,7 +611,7 @@ func (ssg *SSG) static(bucket sst.Bucket) error {
324 		if err != nil {
325 			return err
326 		}
327-		err = ssg.upload(bucket, file.Name(), fp)
328+		err = ssg.upload(logger, bucket, file.Name(), fp)
329 		if err != nil {
330 			return err
331 		}
332@@ -690,12 +646,12 @@ func (ssg *SSG) Prose() error {
333 	ssg.Logger.Info("generating _redirects file", "text", redirectsFile)
334 	// create redirects file
335 	redirects := strings.NewReader(redirectsFile)
336-	err = ssg.upload(bucket, "_redirects", redirects)
337+	err = ssg.upload(ssg.Logger, bucket, "_redirects", redirects)
338 	if err != nil {
339 		return err
340 	}
341 
342-	err = ssg.upload(bucket, "index.html", rdr)
343+	err = ssg.upload(ssg.Logger, bucket, "index.html", rdr)
344 	if err != nil {
345 		return err
346 	}
347@@ -710,13 +666,13 @@ func (ssg *SSG) Prose() error {
348 		}
349 	}()
350 
351-	err = ssg.upload(bucket, "rss.atom", rdr)
352+	err = ssg.upload(ssg.Logger, bucket, "rss.atom", rdr)
353 	if err != nil {
354 		return err
355 	}
356 
357 	ssg.Logger.Info("copying static folder for root", "dir", ssg.StaticDir)
358-	err = ssg.static(bucket)
359+	err = ssg.static(ssg.Logger, bucket)
360 	if err != nil {
361 		return err
362 	}
363@@ -746,87 +702,273 @@ func (ssg *SSG) Prose() error {
364 	return nil
365 }
366 
367+func (ssg *SSG) PostPage(user *db.User, blog *UserBlogData, post *db.Post) (pt *shared.ParsedText, err error) {
368+	// create post file
369+	rdr, wtr := io.Pipe()
370+	var parsed *shared.ParsedText
371+	go func() {
372+		parsed, err = ssg.writePostPage(wtr, user, post, blog)
373+		wtr.Close()
374+		if err != nil {
375+			blog.Logger.Error("post page", "err", err)
376+		}
377+	}()
378+
379+	fname := post.Slug + ".html"
380+	err = ssg.upload(blog.Logger, blog.Bucket, fname, rdr)
381+	if err != nil {
382+		return parsed, err
383+	}
384+	return parsed, nil
385+}
386+
387+func (ssg *SSG) NotFoundPage(logger *slog.Logger, user *db.User, blog *UserBlogData) error {
388+	// create 404 page
389+	logger.Info("generating 404 page")
390+	rdr, wtr := io.Pipe()
391+	go func() {
392+		err := ssg.notFoundPage(wtr, user, blog)
393+		wtr.Close()
394+		if err != nil {
395+			blog.Logger.Error("not found page", "err", err)
396+		}
397+	}()
398+
399+	err := ssg.upload(blog.Logger, blog.Bucket, "404.html", rdr)
400+	if err != nil {
401+		return err
402+	}
403+
404+	return nil
405+}
406+
407+func (ssg *SSG) findPost(username string, bucket sst.Bucket, filename string, modTime time.Time) (*db.Post, error) {
408+	updatedAt := modTime
409+	fp := filepath.Join("prose/", filename)
410+	logger := ssg.Logger.With("filename", fp)
411+	rdr, info, err := ssg.Storage.GetObject(bucket, fp)
412+	if err != nil {
413+		logger.Error("get object", "err", err)
414+		return nil, err
415+	}
416+	txtb, err := io.ReadAll(rdr)
417+	if err != nil {
418+		logger.Error("reader to string", "err", err)
419+		return nil, err
420+	}
421+	txt := string(txtb)
422+	parsed, err := shared.ParseText(txt)
423+	if err != nil {
424+		logger.Error("parse text", "err", err)
425+		return nil, err
426+	}
427+	if parsed.PublishAt == nil || parsed.PublishAt.IsZero() {
428+		ca := info.Metadata.Get("Date")
429+		if ca != "" {
430+			dt, err := time.Parse(time.RFC1123, ca)
431+			if err != nil {
432+				return nil, err
433+			}
434+			parsed.PublishAt = &dt
435+		}
436+	}
437+
438+	slug := utils.SanitizeFileExt(filename)
439+
440+	return &db.Post{
441+		IsVirtual:   true,
442+		Slug:        slug,
443+		Filename:    filename,
444+		FileSize:    len(txt),
445+		Text:        txt,
446+		PublishAt:   parsed.PublishAt,
447+		UpdatedAt:   &updatedAt,
448+		Hidden:      parsed.Hidden,
449+		Description: parsed.Description,
450+		Title:       utils.FilenameToTitle(filename, parsed.Title),
451+		Username:    username,
452+	}, nil
453+}
454+
455+func (ssg *SSG) findPostByName(userID, username string, bucket sst.Bucket, filename string, modTime time.Time) (*db.Post, error) {
456+	post, err := ssg.findPost(username, bucket, filename, modTime)
457+	if err == nil {
458+		return post, nil
459+	}
460+	return ssg.DB.FindPostWithFilename(filename, userID, Space)
461+}
462+
463+func (ssg *SSG) findPosts(blog *UserBlogData) ([]*db.Post, bool, error) {
464+	posts := []*db.Post{}
465+	blog.Logger.Info("finding posts")
466+	objs, _ := ssg.Storage.ListObjects(blog.Bucket, "prose/", true)
467+	if len(objs) > 0 {
468+		blog.Logger.Info("found posts in bucket, using them")
469+	}
470+	for _, obj := range objs {
471+		if obj.IsDir() {
472+			continue
473+		}
474+
475+		ext := filepath.Ext(obj.Name())
476+		if ext == ".md" {
477+			post, err := ssg.findPost(blog.User.Name, blog.Bucket, obj.Name(), obj.ModTime())
478+			if err != nil {
479+				blog.Logger.Error("find post", "err", err, "filename", obj.Name())
480+				continue
481+			}
482+			posts = append(posts, post)
483+		}
484+	}
485+
486+	// we found markdown files in the pgs site so the assumption is
487+	// the pgs site is now the source of truth and we can ignore the posts table
488+	if len(posts) > 0 {
489+		return posts, true, nil
490+	}
491+
492+	blog.Logger.Info("no posts found in bucket, using posts table")
493+	data, err := ssg.DB.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, blog.User.ID, Space)
494+	if err != nil {
495+		return nil, false, err
496+	}
497+	return data.Data, false, nil
498+}
499+
500+type UserBlogData struct {
501+	Bucket   sst.Bucket
502+	User     *db.User
503+	Posts    []*db.Post
504+	Readme   *db.Post
505+	Footer   *db.Post
506+	CSS      *db.Post
507+	NotFound *db.Post
508+	Logger   *slog.Logger
509+}
510+
511 func (ssg *SSG) ProseBlog(user *db.User, bucket sst.Bucket) error {
512 	// programmatically generate redirects file based on aliases
513 	// and other routes that were in prose that need to be available
514 	redirectsFile := "/rss /rss.atom 301\n"
515 	logger := shared.LoggerWithUser(ssg.Logger, user)
516+	logger.Info("generating blog for user")
517 
518-	data, err := ssg.DB.FindPostsForUser(&db.Pager{Num: 1000, Page: 0}, user.ID, Space)
519+	_, err := ssg.DB.FindProjectByName(user.ID, "prose")
520 	if err != nil {
521-		return err
522+		_, err := ssg.DB.InsertProject(user.ID, "prose", "prose")
523+		if err != nil {
524+			return err
525+		}
526+		return ssg.ProseBlog(user, bucket)
527+	}
528+
529+	blog := &UserBlogData{
530+		User:   user,
531+		Bucket: bucket,
532+		Logger: logger,
533 	}
534 
535-	// don't generate a site with 0 posts
536-	if data.Total == 0 {
537+	posts, isVirtual, err := ssg.findPosts(blog)
538+	if err != nil {
539+		// no posts found, bail on generating an empty blog
540+		// TODO: gen the index anyway?
541 		return nil
542 	}
543 
544-	for _, post := range data.Data {
545-		if post.Slug == "" {
546-			logger.Warn("post slug empty, skipping")
547-			continue
548-		}
549+	blog.Posts = posts
550 
551-		logger.Info("generating post", "slug", post.Slug)
552-		fpath := fmt.Sprintf("%s.html", post.Slug)
553+	css, _ := ssg.findPostByName(user.ID, user.Name, bucket, "_styles.css", time.Time{})
554+	if css != nil && !css.IsVirtual {
555+		stylerdr := strings.NewReader(css.Text)
556+		err = ssg.upload(blog.Logger, bucket, "_styles.css", stylerdr)
557+		if err != nil {
558+			return err
559+		}
560+	}
561+	blog.CSS = css
562 
563-		// create post file
564-		rdr, wtr := io.Pipe()
565-		go func() {
566-			aliases, err := ssg.postPage(wtr, user, post)
567-			wtr.Close()
568-			if err != nil {
569-				ssg.Logger.Error("post page", "err", err)
570-			}
571-			// add aliases to redirects file
572-			for _, alias := range aliases {
573-				redirectsFile += fmt.Sprintf("%s %s 200\n", alias, "/"+fpath)
574-			}
575-		}()
576+	readme, _ := ssg.findPostByName(user.ID, user.Name, bucket, "_readme.md", time.Time{})
577+	if readme != nil && !readme.IsVirtual {
578+		rdr := strings.NewReader(readme.Text)
579+		err = ssg.upload(blog.Logger, bucket, "_readme.md", rdr)
580+		if err != nil {
581+			return err
582+		}
583+	}
584+	blog.Readme = readme
585 
586-		err = ssg.upload(bucket, fpath, rdr)
587+	footer, _ := ssg.findPostByName(user.ID, user.Name, bucket, "_footer.md", time.Time{})
588+	if readme != nil && !readme.IsVirtual {
589+		rdr := strings.NewReader(footer.Text)
590+		err = ssg.upload(blog.Logger, bucket, "_footer.md", rdr)
591 		if err != nil {
592 			return err
593 		}
594+	}
595+	blog.Footer = footer
596 
597-		// create raw post file
598-		fpath = post.Slug + ".md"
599-		mdRdr := strings.NewReader(post.Text)
600-		err = ssg.upload(bucket, fpath, mdRdr)
601+	notFound, _ := ssg.findPostByName(user.ID, user.Name, bucket, "_404.md", time.Time{})
602+	if notFound != nil && !notFound.IsVirtual {
603+		rdr := strings.NewReader(notFound.Text)
604+		err = ssg.upload(blog.Logger, bucket, "_404.md", rdr)
605 		if err != nil {
606 			return err
607 		}
608 	}
609+	blog.NotFound = notFound
610 
611-	// create 404 page
612-	logger.Info("generating 404 page")
613-	rdr, wtr := io.Pipe()
614-	go func() {
615-		err = ssg.notFoundPage(wtr, user)
616-		wtr.Close()
617+	tagMap := map[string]string{}
618+	for _, post := range posts {
619+		if post.Slug == "" {
620+			logger.Warn("post slug empty, skipping")
621+			continue
622+		}
623+
624+		logger.Info("generating post", "slug", post.Slug)
625+
626+		parsed, err := ssg.PostPage(user, blog, post)
627 		if err != nil {
628-			ssg.Logger.Error("not found page", "err", err)
629+			return err
630+		}
631+		// add aliases to redirects file
632+		for _, alias := range parsed.Aliases {
633+			redirectsFile += fmt.Sprintf("%s %s 301\n", alias, "/"+post.Slug)
634+		}
635+		for _, tag := range parsed.Tags {
636+			tagMap[tag] = tag
637 		}
638-	}()
639 
640-	err = ssg.upload(bucket, "404.html", rdr)
641+		// create raw post file
642+		// only generate md file if we dont already have it in our pgs site
643+		if !post.IsVirtual {
644+			fpath := post.Slug + ".md"
645+			mdRdr := strings.NewReader(post.Text)
646+			err = ssg.upload(blog.Logger, bucket, fpath, mdRdr)
647+			if err != nil {
648+				return err
649+			}
650+		}
651+	}
652+
653+	err = ssg.NotFoundPage(logger, user, blog)
654 	if err != nil {
655 		return err
656 	}
657 
658-	tags, err := ssg.DB.FindTagsForUser(user.ID, Space)
659-	tags = append(tags, "")
660+	tags := []string{""}
661+	for k := range tagMap {
662+		tags = append(tags, k)
663+	}
664 
665 	// create index files
666 	for _, tag := range tags {
667 		logger.Info("generating blog index page", "tag", tag)
668 		rdr, wtr := io.Pipe()
669 		go func() {
670-			err = ssg.blogPage(wtr, user, tag)
671+			err = ssg.blogPage(wtr, user, blog, tag)
672 			wtr.Close()
673 			if err != nil {
674-				ssg.Logger.Error("blog page", "err", err)
675+				blog.Logger.Error("blog page", "err", err)
676 			}
677 		}()
678 
679@@ -834,24 +976,24 @@ func (ssg *SSG) ProseBlog(user *db.User, bucket sst.Bucket) error {
680 		if tag != "" {
681 			fpath = fmt.Sprintf("index-%s.html", tag)
682 		}
683-		err = ssg.upload(bucket, fpath, rdr)
684+		err = ssg.upload(blog.Logger, bucket, fpath, rdr)
685 		if err != nil {
686 			return err
687 		}
688 	}
689 
690 	logger.Info("generating blog rss page", "tag", "")
691-	rdr, wtr = io.Pipe()
692+	rdr, wtr := io.Pipe()
693 	go func() {
694-		err = ssg.rssBlogPage(wtr, user, "")
695+		err = ssg.rssBlogPage(wtr, user, blog)
696 		wtr.Close()
697 		if err != nil {
698-			ssg.Logger.Error("blog rss page", "err", err)
699+			blog.Logger.Error("blog rss page", "err", err)
700 		}
701 	}()
702 
703 	fpath := "rss.atom"
704-	err = ssg.upload(bucket, fpath, rdr)
705+	err = ssg.upload(blog.Logger, bucket, fpath, rdr)
706 	if err != nil {
707 		return err
708 	}
709@@ -859,31 +1001,25 @@ func (ssg *SSG) ProseBlog(user *db.User, bucket sst.Bucket) error {
710 	logger.Info("generating _redirects file", "text", redirectsFile)
711 	// create redirects file
712 	redirects := strings.NewReader(redirectsFile)
713-	err = ssg.upload(bucket, "_redirects", redirects)
714+	err = ssg.upload(blog.Logger, bucket, "_redirects", redirects)
715 	if err != nil {
716 		return err
717 	}
718 
719-	post, _ := ssg.DB.FindPostWithFilename("_styles.css", user.ID, Space)
720-	if post != nil {
721-		stylerdr := strings.NewReader(post.Text)
722-		err = ssg.upload(bucket, "_styles.css", stylerdr)
723-		if err != nil {
724-			return err
725-		}
726-	}
727-
728 	logger.Info("copying static folder", "dir", ssg.StaticDir)
729-	err = ssg.static(bucket)
730+	err = ssg.static(blog.Logger, bucket)
731 	if err != nil {
732 		return err
733 	}
734 
735-	logger.Info("copying images")
736-	err = ssg.images(user, bucket)
737-	if err != nil {
738-		return err
739+	if !isVirtual {
740+		logger.Info("copying images")
741+		err = ssg.images(user, blog)
742+		if err != nil {
743+			return err
744+		}
745 	}
746 
747+	logger.Info("success!")
748 	return nil
749 }
M shared/mdparser.go
+2, -1
 1@@ -218,6 +218,7 @@ func ParseText(text string) (*ParsedText, error) {
 2 			Tags:       []string{},
 3 			Aliases:    []string{},
 4 			WithStyles: true,
 5+			PublishAt:  &time.Time{},
 6 		},
 7 	}
 8 	hili := highlighting.NewHighlighting(
 9@@ -318,7 +319,7 @@ func ParseText(text string) (*ParsedText, error) {
10 	}
11 	parsed.MetaData.Favicon = favicon
12 
13-	var publishAt *time.Time = nil
14+	publishAt := &time.Time{}
15 	date, err := toString(metaData["date"])
16 	if err != nil {
17 		return &parsed, fmt.Errorf("front-matter field (%s): %w", "date", err)