added tldr pages
sophuwu sophie@sophuwu.com
Mon, 21 Jul 2025 20:50:29 +0200
11 files changed,
416 insertions(+),
64 deletions(-)
M
CFG/CFG.go
→
CFG/CFG.go
@@ -25,6 +25,7 @@ RequireAuth bool = false
PasswdFile string = "/var/lib/manhttpd/authuwu" TldrPages bool = false TldrDir string = "/var/lib/manhttpd/tldr" + TldrGitSrc string = "https://github.com/tldr-pages/tldr.git" EnableStats bool = false StatisticDB string = "/var/lib/manhttpd/manhttpd.db" UseTLS bool = false
M
CFG/etc.conf.go
→
CFG/etc.conf.go
@@ -58,6 +58,7 @@ "require_auth": &RequireAuth,
"passwd_file": &PasswdFile, "tldr_pages": &TldrPages, "tldr_dir": &TldrDir, + "tldr_git_src": &TldrGitSrc, "enable_stats": &EnableStats, "statistic_db": &StatisticDB, "use_tls": &UseTLS,
M
embeds/embeds.go
→
embeds/embeds.go
@@ -99,6 +99,14 @@ f, ok := files[name]
return &f, ok } +func ChkWriteError(w http.ResponseWriter, r *http.Request, err neterr.NetErr, q string) bool { + if err == nil { + return false + } + WriteError(w, r, err, q) + return true +} + func WriteError(w http.ResponseWriter, r *http.Request, err neterr.NetErr, q string) { p := Page{ Title: err.Error().Title(),
M
embeds/static/theme.css
→
embeds/static/theme.css
@@ -240,3 +240,42 @@ .help h3 {
font-size: 1.15rem; margin: 0; } + +.tldr-page { + width: calc(100% - 2ch); + margin: 0; + padding: 1lh 1ch; +} +pre.tldr { + overflow-y: scroll; + padding: 0.5rem; + border-radius: 0.5rem; +} +.tldr code { + padding: 0.2ch 0.5ch; + border-radius: 0.5ch; + margin: 0 0.25ch; +} +pre.tldr, .tldr code { + background: #101015; + border: 1px #696969 solid; +} +p.tldr.list-item { + line-height: 1.75em; +} +.list-item.tldr { + margin: 0; +} +pre.list-item.tldr { + margin-top: 0.3em; + margin-left: 0.5ch; + width: calc(100% - 2rem); +} +p.list-item.tldr, .desc.tldr:first-of-type { + margin-top: 1.5lh; +} +.desc.tldr { + margin: 0; + padding: 0; + height: 1.25lh; +}
M
extra/manhttpd.conf
→
extra/manhttpd.conf
@@ -24,6 +24,7 @@ ## and tldr_dir is set to /var/lib/manhttpd/tldr
## Uncomment the line below to enable tldr_pages #tldr_pages = yes #tldr_dir = /var/lib/manhttpd/tldr +#tldr_git_src = https://github.com/tldr-pages/tldr.git ## manhttpd supports basic authentication. to edit auth credentials:
M
go.sum
→
go.sum
@@ -30,8 +30,9 @@ go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20191105084925-a882066a44e0 h1:QPlSTtPE2k6PZPasQUbzuK3p9JbS+vMXYVto8g/yrsg= golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
M
main.go
→
main.go
@@ -9,6 +9,7 @@ "git.sophuwu.com/manhttpd/CFG"
"git.sophuwu.com/manhttpd/embeds" "git.sophuwu.com/manhttpd/manpage" "git.sophuwu.com/manhttpd/neterr" + "git.sophuwu.com/manhttpd/tldr" "golang.org/x/term" "net/http" "os"@@ -29,6 +30,8 @@ err = flags.ParseArgs()
neterr.ChkFtl("parsing flags:", err) CFG.ParseConfig() embeds.OpenAndParse() + err = tldr.Open() + neterr.ChkFtl("opening tldr pages:", err) } func setPasswd() {@@ -95,28 +98,7 @@
var RxWords = regexp.MustCompile(`("[^"]+")|([^ ]+)`).FindAllString var RxWhatIs = regexp.MustCompile(`([a-zA-Z0-9_\-]+) [(]([0-9a-z]+)[)][\- ]+(.*)`).FindAllStringSubmatch -func SearchHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if neterr.Err400.Is(err) { - embeds.WriteError(w, r, neterr.Err400, r.Form.Get("q")) - return - } - q := r.Form.Get("q") - if q == "" { - http.Redirect(w, r, r.URL.Path, http.StatusFound) - } - if strings.HasPrefix(q, "manweb:") { - http.Redirect(w, r, "?"+q, http.StatusFound) - return - } - if func() bool { - m := manpage.New(q) - return m.Where() == nil - }() { - http.Redirect(w, r, "?"+q, http.StatusFound) - return - } - +func SearchHandler(w http.ResponseWriter, r *http.Request, q string) { var args = RxWords("-lw "+q, -1) for i := range args {@@ -144,29 +126,34 @@ embeds.WriteHtml(w, r, "Search", output, q)
} var PageHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.URL.RawQuery + name = strings.TrimSpace(name) if r.Method == "POST" { - SearchHandler(w, r) + n := r.PostFormValue("q") + n = strings.TrimSpace(n) + if n != "" { + name = n + if strings.ContainsAny(name, `"*?^|`) { + SearchHandler(w, r, name) + return + } + } + } + if name == "" { + embeds.WriteHtml(w, r, "Index", "", "") return } - name := r.URL.RawQuery if name == "manweb:help" { embeds.Help(w, r) return } - - var nerr neterr.NetErr - title := "Index" - var html string - if name != "" { - man := manpage.New(name) - html, nerr = man.Html() - if nerr != nil { - embeds.WriteError(w, r, nerr, name) - return - } - title = man.Name + if manpage.Http(w, r, name) { + return + } + if CFG.TldrPages && tldr.Http(w, r, name) { + return } - embeds.WriteHtml(w, r, title, html, name) + SearchHandler(w, r, name) }) var Handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
M
manpage/manpage.go
→
manpage/manpage.go
@@ -3,56 +3,92 @@
import ( "fmt" "git.sophuwu.com/manhttpd/CFG" + "git.sophuwu.com/manhttpd/embeds" "git.sophuwu.com/manhttpd/neterr" + "net/http" "os/exec" + "path/filepath" "regexp" "strings" ) - -var LinkRemover = regexp.MustCompile(`(<a [^>]*>)|(</a>)`).ReplaceAllString -var HTMLManName = regexp.MustCompile(`(?:<b>)?([a-zA-Z0-9_.:\-]+)(?:</b>)?\(([0-9][0-9a-z]*)\)`) type ManPage struct { Name string Section string - Desc string Path string } -func (m *ManPage) Where() error { - var arg = []string{"-w", m.Name} - if m.Section != "" { - arg = []string{"-w", "-s" + m.Section, m.Name} +func (m *ManPage) Title() string { + if m.Section != "" && m.Name != "" { + return fmt.Sprintf("man %s.%s", m.Name, m.Section) } - b, err := exec.Command(CFG.ManCmd, arg...).Output() + return "" +} + +func ext(s string) (string, string) { + if n := filepath.Ext(s); n != "" { + return s[:len(s)-len(n)], n[1:] + } + return s, "" +} + +var ManDotName = regexp.MustCompile(`^[^ .]+(\.[0-9]+[a-z]*)?$`) + +func (m *ManPage) Find(q string) bool { + if !ManDotName.MatchString(q) { + return false + } + b, err := exec.Command(CFG.ManCmd, "--where", q).Output() + if err != nil { + return false + } m.Path = strings.TrimSpace(string(b)) - return err + m.Name = filepath.Base(m.Path) + m.Name, m.Section = ext(m.Name) + if m.Section == "gz" { + m.Name, m.Section = ext(m.Name) + } + return !(m.Section == "" || m.Name == "" || m.Path == "") } + +var LinkRemover = regexp.MustCompile(`(<a [^>]*>)|(</a>)`).ReplaceAllString +var HTMLManName = regexp.MustCompile(`(?:<b>)?([a-zA-Z0-9_.:\-]+)(?:</b>)?\(([0-9][0-9a-z]*)\)`) + func (m *ManPage) Html() (string, neterr.NetErr) { - if m.Where() != nil { - return "", neterr.Err404 - } b, err := exec.Command(CFG.Mandoc, "-Thtml", "-O", "fragment", m.Path).Output() if err != nil { return "", neterr.Err500 } html := LinkRemover(string(b), "") html = HTMLManName.ReplaceAllStringFunc(html, func(s string) string { - m := HTMLManName.FindStringSubmatch(s) - return fmt.Sprintf(`<a href="?%s.%s">%s(%s)</a>`, m[1], m[2], m[1], m[2]) + mn := HTMLManName.FindStringSubmatch(s) + return fmt.Sprintf(`<a href="?%s.%s">%s(%s)</a>`, mn[1], mn[2], mn[1], mn[2]) }) return html, nil } -var ManDotName = regexp.MustCompile(`^([^ ]+)(?:\.([0-9a-z]+))?$`) - -func New(s string) (m ManPage) { - name := ManDotName.FindStringSubmatch(s) - if len(name) >= 2 { - m.Name = name[1] +func Http(w http.ResponseWriter, r *http.Request, q string) bool { + var m ManPage + if !m.Find(q) { + return false } - if len(name) >= 3 { - m.Section = name[2] + html, err := m.Html() + if embeds.ChkWriteError(w, r, err, q) { + return true } - return + embeds.WriteHtml(w, r, m.Title(), html, q) + return true } + +// var ManDotName = regexp.MustCompile(`^([^ ]+?)(?:\.([0-9a-z]+))?$`) +// +// func New(s string) (m ManPage) { +// name := ManDotName.FindStringSubmatch(s) +// if len(name) >= 2 { +// m.Name = name[1] +// } +// if len(name) >= 3 { +// m.Section = name[2] +// } +// return +// }
M
neterr/neterr.go
→
neterr/neterr.go
@@ -49,7 +49,7 @@ func HTCode(code int, name string, desc ...string) NetErr {
return HTErr{code, name, desc} } -func (h HTErr) Is(err error) bool { +func Is(err error) bool { if err == nil { return false }@@ -63,7 +63,6 @@ Desc []string
} type NetErr interface { Error() HTErr - Is(error) bool } func (e HTErr) Error() HTErr {
A
tldr/tldr.go
@@ -0,0 +1,278 @@
+package tldr + +import ( + "errors" + "fmt" + "git.sophuwu.com/manhttpd/CFG" + "git.sophuwu.com/manhttpd/embeds" + "git.sophuwu.com/manhttpd/neterr" + "net/http" + "os" + "strings" + "time" + + "os/exec" + "path/filepath" +) + +func GitDir() string { return filepath.Join(CFG.TldrDir, "tldr.git") } + +func getGitList(path string, mp *map[string]string) error { + cmd := exec.Command("/bin/git", "--git-dir", GitDir(), "--no-color", "show", "main:"+path) + cmd.Dir = CFG.TldrDir + b, err := cmd.Output() + if err != nil { + return err + } + var k, v string + for _, v = range strings.Split(string(b), "\n") { + if strings.HasSuffix(v, ".md") { + v = strings.TrimSpace(v) + k = strings.TrimSuffix(v, ".md") + v = filepath.Join(path, v) + (*mp)[k] = v + } + } + return nil +} + +var shspt = `#!/bin/bash + +cd '{{ .TldrDir }}' +if [[ -d '{{ .GitDir }}' ]]; then + rm -rf '{{ .GitDir }}' +fi +set -e + +git clone --filter=blob:none --no-checkout '{{ .TldrGitSrc }}' '{{ .GitDir }}' +cd '{{ .GitDir }}' +git sparse-checkout init +git sparse-checkout set pages/linux/*.md pages/common/*.md +git checkout main +mkdir -p '{{ .TldrDir }}/pages' +find "{{ .GitDir }}/pages/common" -type f -name "*.md" -exec cp "{}" '{{ .TldrDir }}/pages/' \; +find "{{ .GitDir }}/pages/linux" -type f -name "*.md" -exec cp "{}" '{{ .TldrDir }}/pages/' \; +` + +var TldrPagesMap = make(map[string]string) + +var PageDir string + +func updateTldrPages() error { + sh := strings.ReplaceAll(shspt, "{{ .TldrDir }}", CFG.TldrDir) + sh = strings.ReplaceAll(sh, "{{ .GitDir }}", GitDir()) + sh = strings.ReplaceAll(sh, "{{ .TldrGitSrc }}", CFG.TldrGitSrc) + upcmd := filepath.Join(CFG.TldrDir, "update.sh") + _ = os.Remove(upcmd) + err := os.WriteFile(upcmd, []byte(sh), 0755) + if err != nil { + return err + } + cmd := exec.Command(upcmd) + cmd.Env = os.Environ() + cmd.Dir = CFG.TldrDir + var b []byte + b, err = cmd.CombinedOutput() + fmt.Println("update tldr pages:", string(b)) + if err != nil { + return err + } + return nil +} + +func Open() error { + if !CFG.TldrPages { + return nil + } + PageDir = filepath.Join(CFG.TldrDir, "pages") + + update := false + st, err := os.Stat(PageDir) + if err != nil && os.IsNotExist(err) { + if err = os.MkdirAll(PageDir, 0755); err != nil { + return err + } + update = true + } else if time.Now().After(st.ModTime().AddDate(0, 0, 14)) { + update = true + } + if update { + if err = updateTldrPages(); err != nil { + return fmt.Errorf("failed to update tldr pages: %w", err) + } + } + + TldrPagesMap = make(map[string]string) + var de []os.DirEntry + de, err = os.ReadDir(PageDir) + if err != nil { + return err + } + var name string + for _, d := range de { + if d.IsDir() { + continue + } + name = strings.TrimSuffix(d.Name(), ".md") + TldrPagesMap[name] = filepath.Join(PageDir, d.Name()) + } + return nil +} + +type TldrPage struct { + Name string + Path string + Content string +} + +func (p *TldrPage) findPath() error { + if p.Name == "" { + return errors.New("tldr page name cannot be empty") + } + var ok bool + p.Path, ok = TldrPagesMap[p.Name] + if !ok { + return errors.New("tldr page not found: " + p.Name) + } + return nil +} + +func (p *TldrPage) open() error { + err := p.findPath() + if err != nil { + return err + } + // cmd := exec.Command("/bin/git", "--git-dir", GitDir(), "--no-color", "show", "main:"+p.Path) + // cmd.Dir = CFG.TldrDir + // b, err := cmd.Output() + b, err := os.ReadFile(p.Path) + p.Content = string(b) + return err +} + +/* + tldr page format: + # tldr page name + > tldr page description + - command 1 + `command 1 usage` + - command 2 + `command 2 usage` + + Specification: + lines beginning with `#` are titles + lines beginning with `>` are descriptions + inline urls, inside <> tags + may contain inline code + lines beginning with `-` list elements + may contain inline code + `codeblocks` on same level + +*/ + +func inlineLink(s string) string { + i := strings.Index(s, "<") + if i < 0 { + return s + } + j := strings.Index(s[i:], ">") + if j < 0 { + return s + } + j += i + return s[:i] + `<a href="` + s[i+1:j] + `">` + s[i+1:j] + `</a>` + inlineLink(s[j+1:]) +} +func inlineCode(s string) string { + i := strings.Index(s, "`") + if i < 0 { + return s + } + j := strings.Index(s[i+1:], "`") + if j < 0 { + return s + } + j += i + 1 + return s[:i] + `<code>` + s[i+1:j] + `</code>` + inlineCode(s[j+1:]) +} + +func inline(s string) string { + s = inlineLink(s) + s = inlineCode(s) + return s +} + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +func (p *TldrPage) HTML() (string, error) { + s := "" + lines := strings.Split(p.Content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if len(line) == 0 { + continue + } + i := strings.Index(line, " ") + if strings.HasPrefix(line, "#") { + if i < 0 { + return "", errors.New("invalid tldr page format: missing space after title") + } + s += `<h1 class="tldr">` + htmlEscape(line[i+1:]) + "</h1>\n" + } else if strings.HasPrefix(line, ">") { + if i < 0 { + return "", errors.New("invalid tldr page format: missing space after description") + } + s += `<p class="desc tldr">` + inline(htmlEscape(line[i+1:])) + "</p>\n" + } else if strings.HasPrefix(line, "-") { + if i < 0 { + return "", errors.New("invalid tldr page format: missing space after list item") + } + s += `<p class="list-item tldr">` + inline(htmlEscape(line[i+1:])) + "</p>\n" + } else if strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`") { + s += `<pre class="list-item tldr">` + htmlEscape(line[1:len(line)-1]) + "</pre>\n" + } else { + s += `<p class="list-item tldr">` + inline(htmlEscape(line)) + "</p>\n" + } + } + if len(s) == 0 { + return "", errors.New("invalid tldr page format: no content found") + } + s = `<div class="tldr-page">` + s + "</div>\n" + return s, nil +} + +func OpenTldrPage(name string) (*TldrPage, neterr.NetErr) { + if name == "" { + return nil, neterr.Err400 + } + page := &TldrPage{Name: name} + err := page.open() + if err != nil { + return nil, neterr.Err404 + } + return page, nil +} + +func Http(w http.ResponseWriter, r *http.Request, q string) bool { + if filepath.Ext(q) != ".tldr" { + return false + } + name := strings.TrimSuffix(q, ".tldr") + page, nerr := OpenTldrPage(name) + if embeds.ChkWriteError(w, r, nerr, q) { + return true + } + html, err := page.HTML() + if err != nil { + embeds.WriteError(w, r, neterr.Err500, q) + return true + } + embeds.WriteHtml(w, r, "TLDR: "+name, html, q) + return true +}