// Command gendocs renders yay's doc/ directory into a static HTML site // suitable for GitHub Pages. // // Usage (from repository root): // // cd scripts && go run ./gendocs [-docs ../doc] [-out ../site] // // Pages produced: // // index.html — landing page // man.html — yay(8) man page (troff → Markdown → HTML) // lua.html — Lua API reference (lua.md → HTML) // init-lua.html — init.lua template as a syntax-highlighted code block package main import ( "bufio" "bytes" "flag" "fmt" "html/template" "os" "path/filepath" "regexp" "strings" bf "github.com/russross/blackfriday/v2" ) func main() { docs := flag.String("docs", "../doc", "documentation source directory") out := flag.String("out", "../site", "output directory") flag.Parse() if err := os.MkdirAll(*out, 0o755); err != nil { fatal(err) } // yay.8 (troff man page) → Markdown → HTML man8 := mustRead(filepath.Join(*docs, "yay.8")) writePage(*out, "man.html", pageData{Title: "yay(8) Manual", Body: template.HTML(mdToHTML(troff2md(man8)))}) // lua.md → HTML with TOC and version badges luaMD := mustRead(filepath.Join(*docs, "lua.md")) luaBody, luaTOC := buildLuaPage(mdToHTML(luaMD)) writePage(*out, "lua.html", pageData{Title: "Lua API", Body: luaBody, TOC: luaTOC}) // init.lua source as a code block initLua := mustRead(filepath.Join(*docs, "init.lua")) initMD := "# init.lua template\n\n```lua\n" + string(initLua) + "```\n" writePage(*out, "init-lua.html", pageData{Title: "init.lua template", Body: template.HTML(mdToHTML([]byte(initMD)))}) // index / landing page indexMD := mustRead(filepath.Join(*docs, "index.md")) writePage(*out, "index.html", pageData{Title: "yay", Body: template.HTML(mdToHTML(indexMD))}) fmt.Printf("site written to %s\n", *out) } // ── HTML template ───────────────────────────────────────────────────────────── var pageTmpl = template.Must(template.New("").Parse(` {{.Title}} — yay {{if .TOC}}{{end}}
{{.Body}}
`)) type pageData struct { Title string Body template.HTML TOC template.HTML // optional; non-empty → TOC sidebar is rendered } func writePage(dir, name string, data pageData) { path := filepath.Join(dir, name) f, err := os.Create(path) if err != nil { fatal(err) } defer f.Close() if err := pageTmpl.Execute(f, data); err != nil { fatal(err) } fmt.Printf(" %s\n", path) } // ── Lua page: TOC + version badges ──────────────────────────────────────────── // reHeading matches an h2 or h3 element with an id attribute as produced by // blackfriday's AutoHeadingIDs extension. The (?s) flag makes . match \n so // multi-line heading content (rare but possible) is covered. var reHeading = regexp.MustCompile(`(?s)<(h[23]) id="([^"]+)"[^>]*>(.*?)`) // reTag strips HTML tags to obtain plain text for TOC labels. var reTag = regexp.MustCompile(`<[^>]+>`) // buildLuaPage extracts headings for the table of contents and returns the // body HTML unchanged alongside the TOC HTML. func buildLuaPage(body []byte) (newBody template.HTML, toc template.HTML) { s := string(body) // Collect headings in document order to build the TOC. type entry struct { level int id string label string // plain text, HTML-escaped when written into TOC } var entries []entry for _, m := range reHeading.FindAllStringSubmatch(s, -1) { lvl := 2 if m[1] == "h3" { lvl = 3 } // Strip inner tags (e.g. ) to get a plain-text label. label := strings.TrimSpace(reTag.ReplaceAllString(m[3], "")) entries = append(entries, entry{lvl, m[2], label}) } // Build the TOC HTML: h3 entries are indented via the .toc-h3 class. var b strings.Builder b.WriteString("On this page\n") return template.HTML(s), template.HTML(b.String()) } // ── Markdown → HTML ─────────────────────────────────────────────────────────── func mdToHTML(src []byte) []byte { // Build renderer with no Smartypants so that -- stays -- (important for // CLI flags like --save). bf.Run's default uses CommonHTMLFlags which // enables Smartypants | SmartypantsDashes and would turn -- into –. renderer := bf.NewHTMLRenderer(bf.HTMLRendererParameters{Flags: 0}) return bf.Run(src, bf.WithRenderer(renderer), bf.WithExtensions(bf.CommonExtensions|bf.AutoHeadingIDs|bf.DefinitionLists), ) } // ── troff man page → Markdown ───────────────────────────────────────────────── // troff2md converts the troff/man macros used in yay.8 to Markdown. // Only macros present in that file are handled; everything else is skipped. func troff2md(src []byte) []byte { var buf bytes.Buffer sc := bufio.NewScanner(bytes.NewReader(src)) inTP := false // next content is the term of a .TP definition pair emit := func(s string) { buf.WriteString(s); buf.WriteByte('\n') } for sc.Scan() { raw := sc.Text() // Blank line: pass through but never emit "****" when inTP. if strings.TrimSpace(raw) == "" { if !inTP { emit("") } continue } // Plain text (not a macro). if !strings.HasPrefix(raw, ".") { text := troffInline(raw) if inTP { emit("**" + text + "**") emit("") // blank line so blackfriday keeps term and body as separate

s inTP = false } else { emit(text) } continue } // Macro line: split on first space to get "MACRO rest". macro, rest, _ := strings.Cut(raw[1:], " ") rest = strings.TrimSpace(rest) switch macro { case "TH": // .TH "NAME" "SECTION" "DATE" "SOURCE" "MANUAL" parts := troffSplitArgs(rest) if len(parts) >= 2 { emit("# " + parts[0] + "(" + parts[1] + ")") emit("") } case "SH": emit("") emit("## " + troffInline(rest)) emit("") inTP = false case "SS": emit("") emit("### " + troffInline(rest)) emit("") case "TP": emit("") inTP = true case "B": if rest == "" { break } emit("**" + troffInline(rest) + "**") if inTP { emit("") inTP = false } case "I": if rest == "" { break } emit("_" + troffInline(rest) + "_") if inTP { emit("") inTP = false } case "BR": emit(troffAlternating(rest, true)) inTP = false case "IR": emit(troffAlternating(rest, false)) inTP = false case "RE", "RS", "PP", "LP", "P", "sp", "br": emit("") // .nh .ad .nf .fi .in .ta and anything else: silently skip. } } return buf.Bytes() } // troffInline converts troff inline escapes to Markdown equivalents. // // \fB ... \fR → **...** // \fI ... \fR → _..._ // \- → - // \% → (removed — optional hyphenation point) // \\ → \ // \ → (pass-through) func troffInline(s string) string { var b strings.Builder cur := byte('R') // current font: R=roman B=bold I=italic openFont := func(f byte) { switch f { case 'B': b.WriteString("**") case 'I': b.WriteByte('_') } } closeFont := func(f byte) { switch f { case 'B': b.WriteString("**") case 'I': b.WriteByte('_') } } for i := 0; i < len(s); { if s[i] != '\\' { // Angle brackets are troff placeholder delimiters (e.g.

), // not HTML. Encode them so blackfriday does not treat them as // inline HTML and produce invalid nesting. switch s[i] { case '<': b.WriteString("<") case '>': b.WriteString(">") default: b.WriteByte(s[i]) } i++ continue } if i+1 >= len(s) { // trailing backslash — skip i++ continue } switch s[i+1] { case 'f': // font change: \fX where X is B, I, R, P, or a digit closeFont(cur) if i+2 < len(s) { switch s[i+2] { case 'B': cur = 'B' openFont('B') case 'I': cur = 'I' openFont('I') default: // R, P, or any other reset cur = 'R' } i += 3 } else { cur = 'R' i += 2 } case '-': b.WriteByte('-') i += 2 case '%': i += 2 // optional break point — discard case '\\': b.WriteByte('\\') i += 2 default: b.WriteByte(s[i+1]) i += 2 } } closeFont(cur) return b.String() } // troffSplitArgs splits a troff argument string into tokens. // Tokens may be double-quoted ("foo bar") or plain words separated by whitespace. // Each token is passed through troffInline to resolve escape sequences. func troffSplitArgs(s string) []string { var out []string s = strings.TrimSpace(s) for len(s) > 0 { if s[0] == '"' { end := strings.IndexByte(s[1:], '"') if end < 0 { out = append(out, troffInline(s[1:])) break } out = append(out, troffInline(s[1:end+1])) s = strings.TrimSpace(s[end+2:]) } else { i := strings.IndexAny(s, " \t") if i < 0 { out = append(out, troffInline(s)) break } out = append(out, troffInline(s[:i])) s = strings.TrimSpace(s[i:]) } } return out } // troffAlternating renders .BR / .IR args by alternating bold (or italic) // and roman styles, concatenated without spaces (the troff convention). func troffAlternating(args string, startBold bool) string { parts := troffSplitArgs(args) var b strings.Builder for i, p := range parts { if (i%2 == 0) == startBold { b.WriteString("**") b.WriteString(p) b.WriteString("**") } else { b.WriteString(p) } } return b.String() } // ── helpers ─────────────────────────────────────────────────────────────────── func mustRead(path string) []byte { data, err := os.ReadFile(path) if err != nil { fatal(err) } return data } func fatal(err error) { fmt.Fprintln(os.Stderr, "gendocs:", err) os.Exit(1) }