1//go:build website
2
3package main
4
5import (
6 "bufio"
7 "bytes"
8 "errors"
9 "flag"
10 "fmt"
11 "html"
12 htmltemplate "html/template"
13 "io"
14 "log"
15 "os"
16 "slices"
17 "strconv"
18 "strings"
19
20 "github.com/russross/blackfriday/v2"
21)
22
23func xcheck(err error, msg string) {
24 if err != nil {
25 log.Fatalf("%s: %s", msg, err)
26 }
27}
28
29func main() {
30 var commithash = os.Getenv("commithash")
31 var commitdate = os.Getenv("commitdate")
32
33 var pageRoot, pageProtocols bool
34 var pageTitle string
35 flag.BoolVar(&pageRoot, "root", false, "is top-level index page, instead of in a sub directory")
36 flag.BoolVar(&pageProtocols, "protocols", false, "is protocols page")
37 flag.StringVar(&pageTitle, "title", "", "html title of page, set to value of link name with a suffix")
38 flag.Parse()
39 args := flag.Args()
40 if len(args) != 1 {
41 flag.Usage()
42 os.Exit(2)
43 }
44 linkname := args[0]
45
46 if pageTitle == "" && linkname != "" {
47 pageTitle = linkname + " - Mox"
48 }
49
50 // Often the website markdown file.
51 input, err := io.ReadAll(os.Stdin)
52 xcheck(err, "read")
53
54 // For rendering the main content of the page.
55 r := &renderer{
56 linkname == "Config reference",
57 "",
58 *blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{HeadingIDPrefix: "hdr-"}),
59 }
60 opts := []blackfriday.Option{
61 blackfriday.WithExtensions(blackfriday.CommonExtensions | blackfriday.AutoHeadingIDs),
62 blackfriday.WithRenderer(r),
63 }
64
65 // Make table of contents of a page, based on h2-links, or "## ..." in markdown.
66 makeTOC := func() ([]byte, []byte) {
67 var title string
68
69 // Get the h2's, split them over the columns.
70 type toclink struct {
71 Title string
72 ID string
73 }
74 var links []toclink
75
76 node := blackfriday.New(opts...).Parse(input)
77 if node == nil {
78 return nil, nil
79 }
80 for c := node.FirstChild; c != nil; c = c.Next {
81 if c.Type != blackfriday.Heading {
82 continue
83 }
84 if c.Level == 1 {
85 title = string(c.FirstChild.Literal)
86 } else if c.Level == 2 {
87 link := toclink{string(c.FirstChild.Literal), c.HeadingID}
88 links = append(links, link)
89 }
90 }
91
92 // We split links over 2 columns if we have quite a few, to keep the page somewhat compact.
93 ncol := 1
94 if len(links) > 6 {
95 ncol = 2
96 }
97
98 n := len(links) / ncol
99 rem := len(links) - ncol*n
100 counts := make([]int, ncol)
101 for i := 0; i < ncol; i++ {
102 counts[i] = n
103 if rem > i {
104 counts[i]++
105 }
106 }
107 toc := `<div class="toc">`
108 toc += "\n"
109 o := 0
110 for _, n := range counts {
111 toc += "<ul>\n"
112 for _, link := range links[o : o+n] {
113 toc += fmt.Sprintf(`<li><a href="#%s">%s</a></li>`, html.EscapeString("hdr-"+link.ID), html.EscapeString(link.Title))
114 toc += "\n"
115 }
116 toc += "</ul>\n"
117 o += n
118 }
119 toc += "</div>\n"
120 var titlebuf []byte
121 if title != "" {
122 titlebuf = []byte(fmt.Sprintf(`<h1 id="%s">%s</h1>`, html.EscapeString("hdr-"+blackfriday.SanitizedAnchorName(title)), html.EscapeString(title)))
123 }
124 return titlebuf, []byte(toc)
125 }
126
127 var output []byte
128 if pageRoot {
129 // Split content into two parts for main page. First two lines are special, for
130 // header.
131 inputstr := string(input)
132 lines := strings.SplitN(inputstr, "\n", 3)
133 if len(lines) < 2 {
134 log.Fatalf("missing header")
135 }
136 inputstr = inputstr[len(lines[0])+1+len(lines[1])+1:]
137 lines[0] = strings.TrimPrefix(lines[0], "#")
138 lines[1] = strings.TrimPrefix(lines[1], "##")
139 sep := "## Quickstart demo"
140 inleft, inright, found := strings.Cut(inputstr, sep)
141 if !found {
142 log.Fatalf("did not find separator %q", sep)
143 }
144 outleft := blackfriday.Run([]byte(inleft), opts...)
145 outright := blackfriday.Run([]byte(sep+inright), opts...)
146 output = []byte(fmt.Sprintf(`
147<div class="rootheader h1">
148 <h1>%s</h1>
149 <h2>%s</h2>
150</div>
151<div class="two"><div>%s</div><div>%s</div></div>`, html.EscapeString(lines[0]), html.EscapeString(lines[1]), outleft, outright))
152 } else if pageProtocols {
153 // ../rfc/index.txt is the standard input. We'll read each topic and the RFCs.
154 topics := parseTopics(input)
155
156 // First part of content is in markdown file.
157 summary, err := os.ReadFile("protocols/summary.md")
158 xcheck(err, "reading protocol summary")
159
160 output = blackfriday.Run(summary, opts...)
161
162 var out bytes.Buffer
163 _, err = out.Write(output)
164 xcheck(err, "write")
165
166 err = protocolTemplate.Execute(&out, map[string]any{"Topics": topics})
167 xcheck(err, "render protocol support")
168
169 output = out.Bytes()
170 } else {
171 // Other pages.
172 xinput := input
173 if bytes.HasPrefix(xinput, []byte("# ")) {
174 xinput = bytes.SplitN(xinput, []byte("\n"), 2)[1]
175 }
176 output = blackfriday.Run(xinput, opts...)
177 titlebuf, toc := makeTOC()
178 output = append(toc, output...)
179 output = append(titlebuf, output...)
180 }
181
182 // HTML preamble.
183 before = strings.Replace(before, "<title>...</title>", "<title>"+html.EscapeString(pageTitle)+"</title>", 1)
184 before = strings.Replace(before, ">"+linkname+"<", ` style="font-weight: bold">`+linkname+"<", 1)
185 if !pageRoot {
186 before = strings.ReplaceAll(before, `"./`, `"../`)
187 }
188 _, err = os.Stdout.Write([]byte(before))
189 xcheck(err, "write")
190
191 // Page content.
192 _, err = os.Stdout.Write(output)
193 xcheck(err, "write")
194
195 // Bottom, HTML closing.
196 after = strings.Replace(after, "[commit]", fmt.Sprintf("%s, commit %s", commitdate, commithash), 1)
197 _, err = os.Stdout.Write([]byte(after))
198 xcheck(err, "write")
199}
200
201// Implementation status of standards/protocols.
202type Status string
203
204const (
205 Implemented Status = "Yes"
206 Partial Status = "Partial"
207 Roadmap Status = "Roadmap"
208 NotImplemented Status = "No"
209 Unknown Status = "?"
210)
211
212// RFC and its implementation status.
213type RFC struct {
214 Number int
215 Title string
216 Status Status
217 StatusClass string
218 Obsolete bool
219}
220
221// Topic is a group of RFC's, typically by protocol, e.g. SMTP.
222type Topic struct {
223 Title string
224 ID string
225 RFCs []RFC
226}
227
228// parse topics and RFCs from ../rfc/index.txt.
229// headings are topics, and hold the RFCs that follow them.
230func parseTopics(input []byte) []Topic {
231 var l []Topic
232 var t *Topic
233
234 b := bufio.NewReader(bytes.NewReader(input))
235 for {
236 line, err := b.ReadString('\n')
237 if line != "" {
238 if strings.HasPrefix(line, "# ") {
239 // Skip topics without RFCs to show on the website.
240 if t != nil && len(t.RFCs) == 0 {
241 l = l[:len(l)-1]
242 }
243 title := strings.TrimPrefix(line, "# ")
244 id := blackfriday.SanitizedAnchorName(title)
245 l = append(l, Topic{Title: title, ID: id})
246 t = &l[len(l)-1] // RFCs will be added to t.
247 continue
248 }
249
250 // Tokens: RFC number, implementation status, is obsolete, title.
251 tokens := strings.Split(line, "\t")
252 if len(tokens) != 4 {
253 continue
254 }
255
256 ignore := strings.HasPrefix(tokens[1], "-")
257 if ignore {
258 continue
259 }
260 status := Status(strings.TrimPrefix(tokens[1], "-"))
261 var statusClass string
262 switch status {
263 case Implemented:
264 statusClass = "implemented"
265 case Partial:
266 statusClass = "partial"
267 case Roadmap:
268 statusClass = "roadmap"
269 case NotImplemented:
270 statusClass = "notimplemented"
271 case Unknown:
272 statusClass = "unknown"
273 default:
274 log.Fatalf("unknown implementation status %q, line %q", status, line)
275 }
276
277 number, err := strconv.ParseInt(tokens[0], 10, 32)
278 xcheck(err, "parsing rfc number")
279 flags := strings.Split(tokens[2], ",")
280 title := tokens[3]
281
282 rfc := RFC{
283 int(number),
284 title,
285 status,
286 statusClass,
287 slices.Contains(flags, "Obs"),
288 }
289 t.RFCs = append(t.RFCs, rfc)
290 }
291 if err == io.EOF {
292 break
293 }
294 xcheck(err, "read line")
295 }
296 // Skip topics without RFCs to show on the website.
297 if t != nil && len(t.RFCs) == 0 {
298 l = l[:len(l)-1]
299 }
300 return l
301}
302
303// renderer is used for all HTML pages, for showing links to h2's on hover, and for
304// specially rendering the config files with links for each config field.
305type renderer struct {
306 codeBlockConfigFile bool // Whether to interpret codeblocks as config files.
307 h2 string // Current title, for config line IDs.
308 blackfriday.HTMLRenderer // Embedded for RenderFooter and RenderHeader.
309}
310
311func (r *renderer) RenderNode(w io.Writer, node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
312 if node.Type == blackfriday.Heading && node.Level == 2 {
313 r.h2 = string(node.FirstChild.Literal)
314
315 id := "hdr-" + blackfriday.SanitizedAnchorName(string(node.FirstChild.Literal))
316 if entering {
317 _, err := fmt.Fprintf(w, `<h2 id="%s">`, id)
318 xcheck(err, "write")
319 } else {
320 _, err := fmt.Fprintf(w, ` <a href="#%s">#</a></h2>`, id)
321 xcheck(err, "write")
322 }
323 return blackfriday.GoToNext
324 }
325 if r.codeBlockConfigFile && node.Type == blackfriday.CodeBlock {
326 if !entering {
327 log.Fatalf("not entering")
328 }
329
330 _, err := fmt.Fprintln(w, `<div class="config">`)
331 xcheck(err, "write")
332 r.writeConfig(w, node.Literal)
333 _, err = fmt.Fprintln(w, "</div>")
334 xcheck(err, "write")
335 return blackfriday.GoToNext
336 }
337 return r.HTMLRenderer.RenderNode(w, node, entering)
338}
339
340func (r *renderer) writeConfig(w io.Writer, data []byte) {
341 var fields []string
342 for _, line := range bytes.Split(data, []byte("\n")) {
343 var attrs, link string
344
345 s := string(line)
346 text := strings.TrimLeft(s, "\t")
347 if strings.HasPrefix(text, "#") {
348 attrs = ` class="comment"`
349 } else if text != "" {
350 // Add id attribute and link to it, based on the nested config fields that lead here.
351 ntab := len(s) - len(text)
352 nfields := ntab + 1
353 if len(fields) >= nfields {
354 fields = fields[:nfields]
355 } else if nfields > len(fields)+1 {
356 xcheck(errors.New("indent jumped"), "write codeblock")
357 } else {
358 fields = append(fields, "")
359 }
360
361 var word string
362 if text == "-" {
363 word = "dash"
364 } else {
365 word = strings.Split(text, ":")[0]
366 }
367 fields[nfields-1] = word
368
369 id := fmt.Sprintf("cfg-%s-%s", blackfriday.SanitizedAnchorName(r.h2), strings.Join(fields, "-"))
370 attrs = fmt.Sprintf(` id="%s"`, id)
371 link = fmt.Sprintf(` <a href="#%s">#</a>`, id)
372 }
373 if s == "" {
374 line = []byte("\n") // Prevent empty, zero-height line.
375 }
376 _, err := fmt.Fprintf(w, "<div%s>%s%s</div>\n", attrs, html.EscapeString(string(line)), link)
377 xcheck(err, "write codeblock")
378 }
379}
380
381var before = `<!doctype html>
382<html>
383 <head>
384 <meta charset="utf-8" />
385 <title>...</title>
386 <meta name="viewport" content="width=device-width, initial-scale=1" />
387 <link rel="icon" href="noNeedlessFaviconRequestsPlease:" />
388 <style>
389* { font-size: 18px; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
390html { scroll-padding-top: 4ex; }
391.textblock { max-width: 50em; margin: 0 auto; }
392p { max-width: 50em; margin-bottom: 2ex; }
393ul, ol { max-width: 50em; margin-bottom: 2ex; }
394pre, code, .config, .config * { font-family: "ubuntu mono", monospace; }
395pre, .config { margin-bottom: 2ex; padding: 1em; background-color: #f8f8f8; border-radius: .25em; }
396pre { white-space: pre-wrap; }
397code { background-color: #eee; }
398pre code { background-color: inherit; }
399h1 { font-size: 1.8em; }
400h2 { font-size: 1.25em; margin-bottom: 1ex; }
401h2 > a { opacity: 0; }
402h2:hover > a { opacity: 1; }
403h3 { font-size: 1.1em; margin-bottom: 1ex; }
404.feature {display: inline-block; width: 30%; margin: 1em; }
405dl { margin: 1em 0; }
406dt { font-weight: bold; margin-bottom: .5ex; }
407dd { max-width: 50em; padding-left: 2em; margin-bottom: 1em; }
408table { margin-bottom: 2ex; }
409
410video { display: block; max-width: 100%; box-shadow: 0 0 20px 0 #ddd; margin: 2ex auto; }
411.img1 { width: 1050px; max-width: 100%; box-shadow: 0 0 20px 0 #bbb; }
412.img2 { width: 1500px; max-width: 100%; box-shadow: 0 0 20px 0 #bbb; }
413
414.implemented { background: linear-gradient(90deg, #bbf05c 0%, #d0ff7d 100%); padding: 0 .25em; display: inline-block; }
415.partial { background: linear-gradient(90deg, #f2f915 0%, #fbff74 100%); padding: 0 .25em; display: inline-block; }
416.roadmap { background: linear-gradient(90deg, #ffbf6c 0%, #ffd49c 100%); padding: 0 .25em; display: inline-block; }
417.notimplemented { background: linear-gradient(90deg, #ffa2fe 0%, #ffbffe 100%); padding: 0 .25em; display: inline-block; }
418.unknown { background: linear-gradient(90deg, #ccc 0%, #e2e2e2 100%); padding: 0 .25em; display: inline-block; }
419
420.config > * { white-space: pre-wrap; }
421.config .comment { color: #777; }
422.config > div > a { opacity: 0; }
423.config > div:hover > a { opacity: 1; }
424.config > div:target { background-color: gold; }
425
426.rfcs .topic a { opacity: 0; }
427.rfcs .topic:hover a { opacity: 1; }
428
429.rootheader { background: linear-gradient(90deg, #ff9d9d 0%, #ffbd9d 100%); display: inline-block; padding: .25ex 3em .25ex 1em; border-radius: .2em; margin-bottom: 2ex; }
430h1, .h1 { margin-bottom: 1ex; }
431h2 { background: linear-gradient(90deg, #6dd5fd 0%, #77e8e3 100%); display: inline-block; padding: 0 .5em 0 .25em; margin-top: 2ex; font-weight: normal; }
432.rootheader h1, .rootheader h2 { background: none; display: block; padding: 0; margin-top: 0; font-weight: bold; margin-bottom: 0; }
433.meta { padding: 1em; display: flex; justify-content: space-between; margin: -1em; }
434.meta > div > * { font-size: .9em; opacity: .5; }
435.meta > nth-child(2) { text-align: right; opacity: .35 }
436
437.navbody { display: flex; }
438.nav { padding: 1em; text-align: right; background-color: #f4f4f4; }
439.nav li { white-space: pre; }
440.main { padding: 1em; }
441.main ul, .main ol { padding-left: 1em; }
442.two { display: flex; gap: 2em; }
443.two > div { flex-basis: 50%; max-width: 50em; }
444.toc { display: flex; gap: 2em; margin-bottom: 3ex; }
445.toc ul { margin-bottom: 0; }
446
447@media (min-width:1025px) {
448 .nav { box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.075); min-height: 100vh; }
449 .main { padding-left: 2em; }
450}
451@media (max-width:1024px) {
452 .navbody { display: block; }
453 .main { box-shadow: 0 0 10px rgba(0, 0, 0, 0.075); }
454 .nav { text-align: left; }
455 .nav ul { display: inline; }
456 .nav li { display: inline; }
457 .nav .linkpad { display: none; }
458 .extlinks { display: none; }
459 .two { display: block; }
460 .two > div { max-width: auto; }
461 .toc { display: block; }
462}
463 </style>
464 </head>
465 <body>
466 <div class="navbody">
467 <nav class="nav">
468 <ul style="list-style: none">
469 <li><a href="./">Mox</a></li>
470 <li><a href="./features/">Features</a></li>
471 <li><a href="./screenshots/">Screenshots</a></li>
472 <li><a href="./install/">Install</a></li>
473 <li><a href="./faq/">FAQ</a></li>
474 <li><a href="./config/">Config reference</a></li>
475 <li><a href="./commands/">Command reference</a></li>
476 <li class="linkpad" style="visibility: hidden; font-weight: bold; height: 0"><a href="./commands/">Command reference</a></li>
477 <li><a href="./protocols/">Protocols</a></li>
478 </ul>
479 <div class="extlinks">
480 <br/>
481 External links:
482 <ul style="list-style: none">
483 <li><a href="https://github.com/mjl-/mox">Sources at github</a></li>
484 <li><a href="https://pkg.go.dev/github.com/mjl-/mox/webapi/">Webapi &amp; webhooks</a></li>
485 </ul>
486 </div>
487 </nav>
488
489 <div class="main">
490`
491
492var after = `
493 <br/>
494 <br/>
495 <div class="meta">
496 <div><a href="https://github.com/mjl-/mox/issues/new?title=website:+">feedback?</a></div>
497 <div><span>[commit]</span></div>
498 </div>
499 </div>
500 </div>
501 </body>
502</html>
503`
504
505// Template for protocol page, minus the first section which is read from
506// protocols/summary.md.
507var protocolTemplate = htmltemplate.Must(htmltemplate.New("protocolsupport").Parse(`
508<table>
509 <tr>
510 <td><span class="implemented">Yes</span></td>
511 <td>All/most of the functionality of the RFC has been implemented.</td>
512 </tr>
513 <tr>
514 <td><span class="partial">Partial</span></td>
515 <td>Some of the functionality from the RFC has been implemented.</td>
516 </tr>
517 <tr>
518 <td><span class="roadmap">Roadmap</span></td>
519 <td>Implementing functionality from the RFC is on the roadmap.</td>
520 </tr>
521 <tr>
522 <td><span class="notimplemented">No</span></td>
523 <td>Functionality from the RFC has not been implemented, is not currently on the roadmap, but may be in the future.</td>
524 </tr>
525 <tr>
526 <td><span class="unknown">?</span></td>
527 <td>Status undecided, unknown or not applicable.</td>
528 </tr>
529</table>
530
531<table class="rfcs">
532 <tr>
533 <th>RFC #</th>
534 <th>Status</th>
535 <th style="text-align: left">Title</th>
536 </tr>
537{{ range .Topics }}
538 <tr>
539 <td colspan="3" style="font-weight: bold; padding: 3ex 0 1ex 0" id="topic-{{ .ID }}" class="topic">{{ .Title }} <a href="#topic-{{ .ID }}">#</a></td>
540 </tr>
541 {{ range .RFCs }}
542 <tr{{ if .Obsolete }} style="opacity: .3"{{ end }}>
543 <td style="text-align: right"><a href="../xr/dev/#code,rfc/{{ .Number }}">{{ .Number }}</a></td>
544 <td style="text-align: center"><span class="{{ .StatusClass }}">{{ .Status }}</span></td>
545 <td>{{ if .Obsolete }}Obsolete: {{ end }}{{ .Title }}</td>
546 </tr>
547 {{ end }}
548{{ end }}
549</table>
550`))
551