package gmi import ( "bytes" "context" "embed" "fmt" "io" "io/fs" "mime" "net/url" "path" "path/filepath" "strconv" "strings" "text/template" "git.sr.ht/~adnano/go-gemini" "code.betamike.com/mediocregopher/mediocre-blog/src/post" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" gmnhg "github.com/tdemin/gmnhg" ) //go:embed tpl var tplFS embed.FS type rendererGetPostsRes struct { Posts []post.StoredPost HasMore bool } type rendererGetPostSeriesNextPreviousRes struct { Next *post.StoredPost Previous *post.StoredPost } type renderer struct { url *url.URL publicURL *url.URL postStore post.Store preprocessFuncs post.PreprocessFunctions } func (r renderer) GetPosts(page, count int) (rendererGetPostsRes, error) { posts, hasMore, err := r.postStore.Get(page, count) return rendererGetPostsRes{posts, hasMore}, err } func (r renderer) GetPostByID(id string) (post.StoredPost, error) { p, err := r.postStore.GetByID(id) if err != nil { return post.StoredPost{}, fmt.Errorf("fetching post %q: %w", id, err) } return p, nil } func (r renderer) GetPostSeriesNextPrevious(p post.StoredPost) (rendererGetPostSeriesNextPreviousRes, error) { seriesPosts, err := r.postStore.GetBySeries(p.Series) if err != nil { return rendererGetPostSeriesNextPreviousRes{}, fmt.Errorf( "fetching posts for series %q: %w", p.Series, err, ) } var ( res rendererGetPostSeriesNextPreviousRes foundThis bool ) for i := range seriesPosts { seriesPost := seriesPosts[i] if seriesPost.ID == p.ID { foundThis = true continue } if !foundThis { res.Next = &seriesPost continue } res.Previous = &seriesPost break } return res, nil } func (r renderer) PostBody(p post.StoredPost) (string, error) { buf := new(bytes.Buffer) if err := p.PreprocessBody(buf, r.preprocessFuncs); err != nil { return "", fmt.Errorf("preprocessing post body: %w", err) } bodyBytes := buf.Bytes() if p.Format == post.FormatMarkdown { gemtextBodyBytes, err := gmnhg.RenderMarkdown(bodyBytes, 0) if err != nil { return "", fmt.Errorf("converting from markdown: %w", err) } bodyBytes = gemtextBodyBytes } return string(bodyBytes), nil } func (r renderer) GetQueryValue(key, def string) string { v := r.url.Query().Get(key) if v == "" { v = def } return v } func (r renderer) GetQueryIntValue(key string, def int) (int, error) { vStr := r.GetQueryValue(key, strconv.Itoa(def)) return strconv.Atoi(vStr) } func (r renderer) GetPath() (string, error) { basePath := filepath.Join("/", r.publicURL.Path) // in case it's empty return filepath.Rel(basePath, r.url.Path) } func (r renderer) Add(a, b int) int { return a + b } func (a *api) tplHandler() (gemini.Handler, error) { blogURL := func(base *url.URL, path string, abs bool) string { // filepath.Join strips trailing slash, but we want to keep it trailingSlash := strings.HasSuffix(path, "/") path = filepath.Join("/", base.Path, path) if trailingSlash && path != "/" { path += "/" } if !abs { return path } u := *base u.Path = path return u.String() } preprocessFuncs := post.PreprocessFunctions{ BlogURL: func(path string) string { return blogURL(a.params.PublicURL, path, false) }, BlogHTTPURL: func(path string) string { return blogURL(a.params.HTTPPublicURL, path, true) }, BlogGeminiURL: func(path string) string { return blogURL(a.params.PublicURL, path, true) }, AssetURL: func(id string) string { path := filepath.Join("assets", id) return blogURL(a.params.PublicURL, path, false) }, PostURL: func(id string) string { path := filepath.Join("posts", id) + ".gmi" return blogURL(a.params.PublicURL, path, false) }, StaticURL: func(path string) string { path = filepath.Join("static", path) return blogURL(a.params.HTTPPublicURL, path, true) }, Image: func(args ...string) (string, error) { var ( id = args[0] descr = "Image" ) if len(args) > 1 { descr = args[1] } path := filepath.Join("assets", id) path = blogURL(a.params.PublicURL, path, false) return fmt.Sprintf("\n=> %s %s", path, descr), nil }, } allTpls := template.New("") allTpls.Funcs(preprocessFuncs.ToFuncMap()) allTpls.Funcs(template.FuncMap{ "PostURLAbs": func(id string) string { path := filepath.Join("posts", id) + ".gmi" return blogURL(a.params.PublicURL, path, true) }, "PostHTTPURL": func(id string) string { path := filepath.Join("posts", id) return preprocessFuncs.BlogHTTPURL(path) }, }) err := fs.WalkDir(tplFS, "tpl", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } body, err := fs.ReadFile(tplFS, path) if err != nil { panic(err) } name := strings.TrimPrefix(path, "tpl/") allTpls, err = allTpls.New(name).Parse(string(body)) if err != nil { return fmt.Errorf("parsing %q as template: %w", path, err) } return nil }) if err != nil { return nil, fmt.Errorf("parsing templates: %w", err) } return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { tplPath := strings.TrimPrefix(r.URL.Path, "/") mimeType := mime.TypeByExtension(path.Ext(r.URL.Path)) ctx = mctx.Annotate(ctx, "url", r.URL, "tplPath", tplPath, "mimeType", mimeType, ) tpl := allTpls.Lookup(tplPath) if tpl == nil { rw.WriteHeader(gemini.StatusNotFound, "Page not found, sorry!") return } if mimeType != "" { rw.SetMediaType(mimeType) } buf := new(bytes.Buffer) err := tpl.Execute(buf, renderer{ url: r.URL, publicURL: a.params.PublicURL, postStore: a.params.PostStore, preprocessFuncs: preprocessFuncs, }) if err != nil { a.params.Logger.Error(ctx, "rendering error", err) rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) return } io.Copy(rw, buf) }), nil }