|
0
|
1 package main
|
|
|
2
|
|
|
3 import (
|
|
|
4 "bufio"
|
|
|
5 "fmt"
|
|
|
6 "io"
|
|
|
7 "os"
|
|
|
8 "os/exec"
|
|
|
9 "path/filepath"
|
|
|
10 "strings"
|
|
|
11 )
|
|
|
12
|
|
2
|
13 const tpldefault = `<!DOCTYPE html>
|
|
0
|
14 <html lang="en">
|
|
|
15 <head>
|
|
|
16 <meta charset="UTF-8">
|
|
|
17 <meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
18 <title>{{TITLE}}</title>
|
|
2
|
19 <link rel="stylesheet" href="{{CSS}}">
|
|
0
|
20 </head>
|
|
|
21 <body>
|
|
|
22 <nav>{{NAV}}</nav>
|
|
2
|
23 <header><h1>{{SITE_TITLE}}</h1></header>
|
|
0
|
24 <main>{{CONTENT}}</main>
|
|
|
25 <footer>
|
|
2
|
26 <p>{{FOOTER_TEXT}}</p>
|
|
|
27 <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p>
|
|
0
|
28 </footer>
|
|
|
29 </body>
|
|
|
30 </html>`
|
|
|
31
|
|
2
|
32 type config struct {
|
|
|
33 headertext string
|
|
|
34 footertext string
|
|
|
35 cssfile string
|
|
|
36 tplfile string
|
|
|
37 }
|
|
|
38
|
|
0
|
39 type section struct {
|
|
|
40 title string
|
|
2
|
41 index string
|
|
|
42 pages []page
|
|
0
|
43 }
|
|
|
44
|
|
|
45 type page struct {
|
|
|
46 title string
|
|
|
47 path string
|
|
|
48 }
|
|
|
49
|
|
2
|
50 func ParseINI(r io.Reader) (map[string]map[string]string, error) {
|
|
|
51 res := make(map[string]map[string]string)
|
|
|
52 sec := "default"
|
|
|
53 scanner := bufio.NewScanner(r)
|
|
|
54 for scanner.Scan() {
|
|
|
55 line := strings.TrimSpace(scanner.Text())
|
|
|
56 if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
|
|
57 continue
|
|
|
58 }
|
|
|
59 if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
|
|
60 sec = line[1 : len(line)-1]
|
|
|
61 if res[sec] == nil {
|
|
|
62 res[sec] = make(map[string]string)
|
|
|
63 }
|
|
|
64 continue
|
|
|
65 }
|
|
|
66 if k, v, ok := strings.Cut(line, "="); ok {
|
|
|
67 if res[sec] == nil {
|
|
|
68 res[sec] = make(map[string]string)
|
|
|
69 }
|
|
|
70 res[sec][strings.TrimSpace(k)] = strings.TrimSpace(v)
|
|
|
71 }
|
|
|
72 }
|
|
|
73 return res, scanner.Err()
|
|
|
74 }
|
|
0
|
75
|
|
2
|
76 func loadconfig(src string) config {
|
|
|
77 cfg := config{
|
|
|
78 headertext: "My Site",
|
|
|
79 footertext: "© Me",
|
|
|
80 cssfile: "/style.css",
|
|
|
81 }
|
|
|
82 f, err := os.Open(filepath.Join(src, "qwb.ini"))
|
|
|
83 if err != nil {
|
|
|
84 return cfg
|
|
|
85 }
|
|
|
86 defer f.Close()
|
|
|
87 ini, err := ParseINI(f)
|
|
|
88 if err != nil {
|
|
|
89 return cfg
|
|
|
90 }
|
|
|
91 s := ini["site"]
|
|
|
92 set := func(key string, target *string) {
|
|
|
93 if v, ok := s[key]; ok {
|
|
|
94 *target = v
|
|
|
95 }
|
|
|
96 }
|
|
|
97 set("header", &cfg.headertext)
|
|
|
98 set("footer", &cfg.footertext)
|
|
|
99 set("style", &cfg.cssfile)
|
|
|
100 set("template", &cfg.tplfile)
|
|
|
101 return cfg
|
|
|
102 }
|
|
|
103
|
|
|
104 func loadtemplate(cfg config) string {
|
|
|
105 if cfg.tplfile == "" {
|
|
|
106 return tpldefault
|
|
|
107 }
|
|
|
108 b, err := os.ReadFile(cfg.tplfile)
|
|
|
109 if err != nil {
|
|
|
110 fmt.Fprintf(os.Stderr, "cannot read template %s: %v", cfg.tplfile, err)
|
|
|
111 return tpldefault
|
|
|
112 }
|
|
|
113 return string(b)
|
|
|
114 }
|
|
|
115
|
|
|
116 func collectsections(root, siteTitle string) (section, []section) {
|
|
|
117 var subs []section
|
|
|
118 root_ := section{title: siteTitle}
|
|
0
|
119 entries, _ := os.ReadDir(root)
|
|
|
120 for _, e := range entries {
|
|
|
121 full := filepath.Join(root, e.Name())
|
|
|
122 if e.IsDir() {
|
|
2
|
123 s := scansection(full, root)
|
|
0
|
124 if s.index != "" || len(s.pages) > 0 {
|
|
|
125 subs = append(subs, s)
|
|
|
126 }
|
|
|
127 continue
|
|
|
128 }
|
|
|
129 if !strings.HasSuffix(e.Name(), ".md") {
|
|
|
130 continue
|
|
|
131 }
|
|
|
132 if e.Name() == "index.md" {
|
|
2
|
133 root_.index = "/index.html"
|
|
0
|
134 } else {
|
|
2
|
135 rel, _ := filepath.Rel(root, full)
|
|
|
136 root_.pages = append(root_.pages, page{
|
|
|
137 title: titlefromname(e.Name()),
|
|
|
138 path: "/" + strings.TrimSuffix(rel, ".md") + ".html",
|
|
|
139 })
|
|
|
140 }
|
|
|
141 }
|
|
|
142 return root_, subs
|
|
|
143 }
|
|
|
144
|
|
|
145 func scansection(dir, root string) section {
|
|
|
146 s := section{title: titlefromname(filepath.Base(dir))}
|
|
|
147 entries, _ := os.ReadDir(dir)
|
|
|
148 for _, e := range entries {
|
|
|
149 if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
|
|
150 continue
|
|
|
151 }
|
|
|
152 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name()))
|
|
|
153 htmlpath := "/" + strings.TrimSuffix(rel, ".md") + ".html"
|
|
|
154 if e.Name() == "index.md" {
|
|
|
155 s.index = htmlpath
|
|
|
156 } else {
|
|
|
157 s.pages = append(s.pages, page{titlefromname(e.Name()), htmlpath})
|
|
0
|
158 }
|
|
|
159 }
|
|
|
160 return s
|
|
|
161 }
|
|
|
162
|
|
2
|
163 func navlink(b *strings.Builder, p page, cur string) {
|
|
0
|
164 if p.path == cur {
|
|
|
165 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title)
|
|
|
166 } else {
|
|
|
167 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title)
|
|
|
168 }
|
|
|
169 }
|
|
|
170
|
|
2
|
171 func buildnav(root section, subs []section, cur string) string {
|
|
0
|
172 var b strings.Builder
|
|
|
173 b.WriteString("<ul>\n")
|
|
2
|
174 if root.index != "" {
|
|
|
175 navlink(&b, page{"Home", root.index}, cur)
|
|
0
|
176 }
|
|
2
|
177 for _, p := range root.pages {
|
|
|
178 navlink(&b, p, cur)
|
|
0
|
179 }
|
|
|
180 for _, s := range subs {
|
|
|
181 link := s.index
|
|
|
182 if link == "" && len(s.pages) > 0 {
|
|
|
183 link = s.pages[0].path
|
|
|
184 }
|
|
|
185 if link == "" {
|
|
|
186 continue
|
|
|
187 }
|
|
2
|
188 navlink(&b, page{s.title, link}, cur)
|
|
0
|
189 }
|
|
|
190 b.WriteString("</ul>\n")
|
|
|
191 for _, s := range subs {
|
|
2
|
192 if !sectioncontains(s, cur) {
|
|
0
|
193 continue
|
|
|
194 }
|
|
|
195 total := len(s.pages)
|
|
|
196 if s.index != "" {
|
|
|
197 total++
|
|
|
198 }
|
|
|
199 if total < 2 {
|
|
|
200 break
|
|
|
201 }
|
|
|
202 b.WriteString("<ul>\n")
|
|
|
203 if s.index != "" {
|
|
2
|
204 navlink(&b, page{"Index", s.index}, cur)
|
|
0
|
205 }
|
|
|
206 for _, p := range s.pages {
|
|
2
|
207 navlink(&b, p, cur)
|
|
0
|
208 }
|
|
|
209 b.WriteString("</ul>\n")
|
|
|
210 break
|
|
|
211 }
|
|
|
212 return b.String()
|
|
|
213 }
|
|
|
214
|
|
2
|
215 func sectioncontains(s section, cur string) bool {
|
|
0
|
216 if s.index == cur {
|
|
|
217 return true
|
|
|
218 }
|
|
|
219 for _, p := range s.pages {
|
|
|
220 if p.path == cur {
|
|
|
221 return true
|
|
|
222 }
|
|
|
223 }
|
|
|
224 return false
|
|
|
225 }
|
|
|
226
|
|
2
|
227 func mdtohtml(path string) (string, error) {
|
|
0
|
228 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml")
|
|
|
229 f, err := os.Open(path)
|
|
|
230 if err != nil {
|
|
|
231 return "", err
|
|
|
232 }
|
|
|
233 defer f.Close()
|
|
|
234 var buf strings.Builder
|
|
|
235 cmd.Stdin = f
|
|
|
236 cmd.Stdout = &buf
|
|
|
237 cmd.Stderr = os.Stderr
|
|
|
238 if err := cmd.Run(); err != nil {
|
|
|
239 return "", err
|
|
|
240 }
|
|
|
241 return buf.String(), nil
|
|
|
242 }
|
|
|
243
|
|
2
|
244 func titlefromname(name string) string {
|
|
0
|
245 name = strings.TrimSuffix(name, ".md")
|
|
|
246 name = strings.ReplaceAll(name, "-", " ")
|
|
|
247 if len(name) > 0 {
|
|
|
248 name = strings.ToUpper(name[:1]) + name[1:]
|
|
|
249 }
|
|
|
250 return name
|
|
|
251 }
|
|
|
252
|
|
2
|
253 func fixlinks(s string) string {
|
|
0
|
254 return strings.NewReplacer(
|
|
2
|
255 ".md)", ".html)",
|
|
|
256 ".md\"", ".html\"",
|
|
|
257 ".md'", ".html'",
|
|
|
258 ".md#", ".html#",
|
|
|
259 ".md>", ".html>",
|
|
|
260 ".md ", ".html ",
|
|
0
|
261 ".md,", ".html,",
|
|
|
262 ).Replace(s)
|
|
|
263 }
|
|
|
264
|
|
2
|
265 func copyfile(src, dst string) error {
|
|
0
|
266 in, err := os.Open(src)
|
|
|
267 if err != nil {
|
|
|
268 return err
|
|
|
269 }
|
|
|
270 defer in.Close()
|
|
|
271 out, err := os.Create(dst)
|
|
|
272 if err != nil {
|
|
|
273 return err
|
|
|
274 }
|
|
|
275 defer out.Close()
|
|
|
276 _, err = io.Copy(out, in)
|
|
|
277 return err
|
|
|
278 }
|
|
|
279
|
|
|
280 func main() {
|
|
|
281 if len(os.Args) != 3 {
|
|
|
282 fmt.Fprintln(os.Stderr, "usage: qwb <in> <out>")
|
|
|
283 os.Exit(1)
|
|
|
284 }
|
|
|
285 src, out := os.Args[1], os.Args[2]
|
|
|
286
|
|
|
287 if entries, err := os.ReadDir(out); err == nil && len(entries) > 0 {
|
|
|
288 fmt.Fprintf(os.Stderr, "qwb: %s is not empty, overwrite? [y/N] ", out)
|
|
|
289 s, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
|
290 if strings.TrimSpace(strings.ToLower(s)) != "y" {
|
|
|
291 fmt.Fprintln(os.Stderr, "aborted")
|
|
|
292 os.Exit(1)
|
|
|
293 }
|
|
|
294 if err := os.RemoveAll(out); err != nil {
|
|
|
295 fmt.Fprintln(os.Stderr, err)
|
|
|
296 os.Exit(1)
|
|
|
297 }
|
|
|
298 }
|
|
|
299
|
|
2
|
300 cfg := loadconfig(src)
|
|
|
301 tmpl := loadtemplate(cfg)
|
|
|
302 rootsec, subs := collectsections(src, cfg.headertext)
|
|
0
|
303
|
|
|
304 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
|
|
305 if err != nil {
|
|
|
306 return err
|
|
|
307 }
|
|
|
308 rel, _ := filepath.Rel(src, path)
|
|
|
309 outpath := filepath.Join(out, rel)
|
|
|
310 if d.IsDir() {
|
|
|
311 return os.MkdirAll(outpath, 0755)
|
|
|
312 }
|
|
|
313 if !strings.HasSuffix(path, ".md") {
|
|
2
|
314 return copyfile(path, outpath)
|
|
0
|
315 }
|
|
2
|
316 body, err := mdtohtml(path)
|
|
0
|
317 if err != nil {
|
|
|
318 return err
|
|
|
319 }
|
|
2
|
320 body = fixlinks(body)
|
|
0
|
321 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html"
|
|
2
|
322 title := cfg.headertext
|
|
0
|
323 if filepath.Base(path) != "index.md" {
|
|
2
|
324 title = cfg.headertext + " | " + titlefromname(filepath.Base(path))
|
|
0
|
325 }
|
|
2
|
326 pg:= strings.ReplaceAll(tmpl, "{{TITLE}}", title)
|
|
|
327 pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", cfg.headertext)
|
|
|
328 pg = strings.ReplaceAll(pg, "{{FOOTER_TEXT}}", cfg.footertext)
|
|
|
329 pg = strings.ReplaceAll(pg, "{{CSS}}", cfg.cssfile)
|
|
|
330 pg = strings.ReplaceAll(pg, "{{NAV}}", buildnav(rootsec, subs, cur))
|
|
0
|
331 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body)
|
|
|
332 outpath = strings.TrimSuffix(outpath, ".md") + ".html"
|
|
|
333 return os.WriteFile(outpath, []byte(pg), 0644)
|
|
|
334 })
|
|
|
335
|
|
|
336 if err != nil {
|
|
|
337 fmt.Fprintln(os.Stderr, err)
|
|
|
338 os.Exit(1)
|
|
|
339 }
|
|
|
340 } |