this repo has no description
1package main
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "io"
8 "io/fs"
9 "os"
10 "path/filepath"
11 "slices"
12 "strings"
13 "time"
14
15 "github.com/briandowns/spinner"
16 "go.seankhliao.com/mono/webstyle"
17 "maragu.dev/gomponents"
18 "maragu.dev/gomponents/html"
19)
20
21const (
22 singleKey = ":single"
23)
24
25func stripTitles(src []byte) (page []byte, title, subtitle string) {
26 buf := new(bytes.Buffer)
27 sc := bufio.NewScanner(bytes.NewReader(src))
28 for sc.Scan() {
29 b := sc.Bytes()
30 switch {
31 case bytes.HasPrefix(b, []byte("# ")):
32 title = string(b[2:])
33 case bytes.HasPrefix(b, []byte("## ")):
34 subtitle = string(b[3:])
35 default:
36 buf.Write(b)
37 buf.WriteRune('\n')
38 }
39 }
40 page = buf.Bytes()
41 return page, title, subtitle
42}
43
44func renderSingle(in string, compact bool) (map[string]*bytes.Buffer, error) {
45 b, err := os.ReadFile(in)
46 if err != nil {
47 return nil, fmt.Errorf("read file: %w", err)
48 }
49 b, title, subtitle := stripTitles(b)
50 rawHTML, rawCSS, err := webstyle.Markdown(b)
51 if err != nil {
52 return nil, fmt.Errorf("parse markdown: %w", err)
53 }
54 buf := new(bytes.Buffer)
55 o := webstyle.NewOptions(
56 title,
57 subtitle,
58 []gomponents.Node{gomponents.Raw(string(rawHTML))})
59 o.CustomCSS = string(rawCSS)
60 o.CompactStyle = compact
61 err = webstyle.Structured(buf, o)
62 if err != nil {
63 return nil, fmt.Errorf("render result: %w", err)
64 }
65 return map[string]*bytes.Buffer{singleKey: buf}, nil
66}
67
68func renderMulti(in, gtm, baseURL string, compact bool) (map[string]*bytes.Buffer, error) {
69 var countFiles int
70 fsys := os.DirFS(in)
71 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
72 if err != nil || d.IsDir() {
73 return err
74 }
75 countFiles++
76 return nil
77 })
78 if err != nil {
79 return nil, fmt.Errorf("walk source: %w", err)
80 }
81 spin := spinner.New(spinner.CharSets[39], 100*time.Millisecond)
82 spin.Start()
83 defer spin.Stop()
84
85 rendered := make(map[string]*bytes.Buffer)
86 rendered["sitemap.txt"] = new(bytes.Buffer)
87 err = fs.WalkDir(fsys, ".", walk(fsys, spin, rendered, gtm, baseURL, compact))
88 if err != nil {
89 return nil, fmt.Errorf("process source: %w", err)
90 }
91
92 spin.FinalMSG = fmt.Sprintf("%3d rendered pages\n", len(rendered))
93
94 return rendered, nil
95}
96
97func walk(fsys fs.FS, spin *spinner.Spinner, rendered map[string]*bytes.Buffer, gtm, baseURL string, compact bool) fs.WalkDirFunc {
98 var idx int
99 return func(p string, d fs.DirEntry, openErr error) error {
100 if openErr != nil || d.IsDir() {
101 return openErr
102 }
103
104 idx++
105 spin.Suffix = fmt.Sprintf("%3d processing %q", idx, p)
106
107 inFile, openErr := fsys.Open(p)
108 if openErr != nil {
109 return fmt.Errorf("open file: %w", openErr)
110 }
111 defer inFile.Close()
112
113 buf := new(bytes.Buffer)
114 if strings.HasSuffix(p, ".draft.md") {
115 // skip drafts
116 return nil
117 }
118 if strings.HasSuffix(p, ".md") {
119 b, err := io.ReadAll(inFile)
120 if err != nil {
121 return fmt.Errorf("read file: %w", err)
122 }
123 b, title, subtitle := stripTitles(b)
124 rawHTML, rawCSS, err := webstyle.Markdown(b)
125 if err != nil {
126 return fmt.Errorf("render markdown: %w", err)
127 }
128
129 u := baseURL + canonicalPathFromRelPath(p)
130 o := webstyle.NewOptions(
131 subtitle,
132 title,
133 []gomponents.Node{gomponents.Raw(string(rawHTML))},
134 )
135 o.CompactStyle = compact
136 o.CanonicalURL = u
137 o.CustomCSS = string(rawCSS)
138
139 if title == "" {
140 return fmt.Errorf("missing title")
141 }
142
143 if p == "index.md" { // root index
144 o.HideTitles = true
145 } else if strings.HasSuffix(p, "/index.md") { // all other directory indexes
146 var list gomponents.Node
147 list, err = directoryList(fsys, p)
148 if err != nil {
149 return err
150 }
151 o.Content = append(o.Content, list)
152 }
153
154 err = webstyle.Structured(buf, o)
155 if err != nil {
156 return fmt.Errorf("render: %w", err)
157 }
158
159 fmt.Fprintf(rendered["sitemap.txt"], "%s\n", u)
160 p = p[:len(p)-3] + ".html"
161 } else if strings.HasSuffix(p, ".table.cue") {
162 u := baseURL + canonicalPathFromRelPath(p)
163 openErr = processTable(buf, inFile, u, gtm)
164 if openErr != nil {
165 return fmt.Errorf("process table: %w", openErr)
166 }
167 fmt.Fprintf(rendered["sitemap.txt"], "%s\n", u)
168 p = p[:len(p)-len(".table.cue")] + ".html"
169 } else if strings.HasSuffix(p, ".events.cue") {
170 u := baseURL + canonicalPathFromRelPath(p)
171 openErr = processEvents(buf, inFile, u, gtm)
172 if openErr != nil {
173 return fmt.Errorf("process events: %w", openErr)
174 }
175 fmt.Fprintf(rendered["sitemap.txt"], "%s\n", u)
176 p = p[:len(p)-len(".events.cue")] + ".html"
177 } else {
178 _, openErr = io.Copy(buf, inFile)
179 if openErr != nil {
180 return fmt.Errorf("copy: %w", openErr)
181 }
182 }
183
184 rendered[p] = buf
185
186 return nil
187 }
188}
189
190func directoryList(fsys fs.FS, p string) (gomponents.Node, error) {
191 des, err := fs.ReadDir(fsys, filepath.Dir(p))
192 if err != nil {
193 return nil, fmt.Errorf("read dir: %w", err)
194 }
195
196 // reverse order
197 slices.SortFunc(des, func(a, b fs.DirEntry) int {
198 return strings.Compare(b.Name(), a.Name())
199 })
200
201 entries := make([]gomponents.Node, 0, len(des))
202 for _, de := range des {
203 if de.IsDir() || de.Name() == "index.md" {
204 continue
205 }
206 n := de.Name() // 120XX-YY-ZZ-some-title.md
207 if strings.HasPrefix(n, "120") && len(n) > 15 && n[11] == '-' {
208 entries = append(entries, html.Li(
209 html.Time(
210 html.DateTime(n[1:11]), // 20XX-YY-ZZ
211 gomponents.Text(n[:11]), // 120XX-YY-ZZ
212 ),
213 gomponents.Text(" | "),
214 html.A(
215 html.Href(n[:len(n)-3]+"/"), // 120XX-YY-ZZ-some-title/
216 gomponents.Text(strings.ReplaceAll(n[12:len(n)-3], "-", " ")), // some title
217 ),
218 ))
219 }
220 }
221 return html.Ul(entries...), nil
222}
223
224func canonicalPathFromRelPath(in string) string {
225 in = strings.TrimSuffix(in, ".md")
226 in = strings.TrimSuffix(in, ".html")
227 in = strings.TrimSuffix(in, ".table.cue")
228 in = strings.TrimSuffix(in, ".events.cue")
229 in = strings.TrimSuffix(in, "index")
230 if in == "" {
231 return "/"
232 } else if in[len(in)-1] == '/' {
233 return "/" + in
234 } else if strings.HasPrefix(in, "static/") {
235 return "/" + in
236 }
237 return "/" + in + "/"
238}