comparison qwb.go @ 2:3222f88c0afe

refactored + new css
author Atarwn Gard <a@qwa.su>
date Sat, 14 Mar 2026 12:03:52 +0500
parents ac64aa92dea1
children ce2b6dde4c10
comparison
equal deleted inserted replaced
1:72124c0555c8 2:3222f88c0afe
8 "os/exec" 8 "os/exec"
9 "path/filepath" 9 "path/filepath"
10 "strings" 10 "strings"
11 ) 11 )
12 12
13 const siteTitle = "My Site" 13 const tpldefault = `<!DOCTYPE html>
14
15 const tmpl = `<!DOCTYPE html>
16 <html lang="en"> 14 <html lang="en">
17 <head> 15 <head>
18 <meta charset="UTF-8"> 16 <meta charset="UTF-8">
19 <meta name="viewport" content="width=device-width, initial-scale=1"> 17 <meta name="viewport" content="width=device-width, initial-scale=1">
20 <title>{{TITLE}}</title> 18 <title>{{TITLE}}</title>
21 <link rel="stylesheet" href="/x.css"> 19 <link rel="stylesheet" href="{{CSS}}">
22 </head> 20 </head>
23 <body> 21 <body>
24 <header><h1>` + siteTitle + `</h1></header>
25 <nav>{{NAV}}</nav> 22 <nav>{{NAV}}</nav>
23 <header><h1>{{SITE_TITLE}}</h1></header>
26 <main>{{CONTENT}}</main> 24 <main>{{CONTENT}}</main>
27 <footer> 25 <footer>
28 <p>` + siteTitle + `</p> 26 <p>{{FOOTER_TEXT}}</p>
29 <p>&copy; Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p> 27 <p>Built with <a href="https://hg.reactionary.software/qwb">qwb</a></p>
30 </footer> 28 </footer>
31 </body> 29 </body>
32 </html>` 30 </html>`
33 31
34 // section — top-level directory, each with its own index and pages 32 type config struct {
33 headertext string
34 footertext string
35 cssfile string
36 tplfile string
37 }
38
35 type section struct { 39 type section struct {
36 title string 40 title string
37 index string // /section/index.html, or "" if none 41 index string
38 pages []page // non-index .md files within this section 42 pages []page
39 } 43 }
40 44
41 type page struct { 45 type page struct {
42 title string 46 title string
43 path string 47 path string
44 } 48 }
45 49
46 // collectSections scans one level deep: root files go into a synthetic root 50 func ParseINI(r io.Reader) (map[string]map[string]string, error) {
47 // section, each subdirectory becomes its own section. 51 res := make(map[string]map[string]string)
48 func collectSections(root string) (rootSection section, subs []section) { 52 sec := "default"
49 rootSection.title = siteTitle 53 scanner := bufio.NewScanner(r)
50 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 }
75
76 func loadconfig(src string) config {
77 cfg := config{
78 headertext: "My Site",
79 footertext: "&copy; 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}
51 entries, _ := os.ReadDir(root) 119 entries, _ := os.ReadDir(root)
52 for _, e := range entries { 120 for _, e := range entries {
53 full := filepath.Join(root, e.Name()) 121 full := filepath.Join(root, e.Name())
54 if e.IsDir() { 122 if e.IsDir() {
55 s := scanSection(full, root) 123 s := scansection(full, root)
56 if s.index != "" || len(s.pages) > 0 { 124 if s.index != "" || len(s.pages) > 0 {
57 subs = append(subs, s) 125 subs = append(subs, s)
58 } 126 }
59 continue 127 continue
60 } 128 }
61 if strings.HasSuffix(e.Name(), ".md") { 129 if !strings.HasSuffix(e.Name(), ".md") {
62 if e.Name() == "index.md" { 130 continue
63 rootSection.index = "/index.html" 131 }
64 } else { 132 if e.Name() == "index.md" {
65 rel, _ := filepath.Rel(root, full) 133 root_.index = "/index.html"
66 rootSection.pages = append(rootSection.pages, page{ 134 } else {
67 title: titleFromName(e.Name()), 135 rel, _ := filepath.Rel(root, full)
68 path: "/" + strings.TrimSuffix(rel, ".md") + ".html", 136 root_.pages = append(root_.pages, page{
69 }) 137 title: titlefromname(e.Name()),
70 } 138 path: "/" + strings.TrimSuffix(rel, ".md") + ".html",
71 } 139 })
72 } 140 }
73 return 141 }
74 } 142 return root_, subs
75 143 }
76 func scanSection(dir, root string) section { 144
77 s := section{title: titleFromName(filepath.Base(dir))} 145 func scansection(dir, root string) section {
146 s := section{title: titlefromname(filepath.Base(dir))}
78 entries, _ := os.ReadDir(dir) 147 entries, _ := os.ReadDir(dir)
79 for _, e := range entries { 148 for _, e := range entries {
80 if e.IsDir() { 149 if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
81 continue // only one level deep
82 }
83 if !strings.HasSuffix(e.Name(), ".md") {
84 continue 150 continue
85 } 151 }
86 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name())) 152 rel, _ := filepath.Rel(root, filepath.Join(dir, e.Name()))
87 htmlPath := "/" + strings.TrimSuffix(rel, ".md") + ".html" 153 htmlpath := "/" + strings.TrimSuffix(rel, ".md") + ".html"
88 if e.Name() == "index.md" { 154 if e.Name() == "index.md" {
89 s.index = htmlPath 155 s.index = htmlpath
90 } else { 156 } else {
91 s.pages = append(s.pages, page{titleFromName(e.Name()), htmlPath}) 157 s.pages = append(s.pages, page{titlefromname(e.Name()), htmlpath})
92 } 158 }
93 } 159 }
94 return s 160 return s
95 } 161 }
96 162
97 func navLink(b *strings.Builder, p page, cur string) { 163 func navlink(b *strings.Builder, p page, cur string) {
98 if p.path == cur { 164 if p.path == cur {
99 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title) 165 fmt.Fprintf(b, "<li><b><a href=\"%s\">%s</a></b></li>\n", p.path, p.title)
100 } else { 166 } else {
101 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title) 167 fmt.Fprintf(b, "<li><a href=\"%s\">%s</a></li>\n", p.path, p.title)
102 } 168 }
103 } 169 }
104 170
105 // buildNav produces one or two <ul> blocks. 171 func buildnav(root section, subs []section, cur string) string {
106 // Top <ul>: root index + root loose pages + one entry per sub-section (via its index).
107 // Second <ul>: pages of the active sub-section, only when section has 2+ pages.
108 func buildNav(rootSec section, subs []section, cur string) string {
109 var b strings.Builder 172 var b strings.Builder
110
111 b.WriteString("<ul>\n") 173 b.WriteString("<ul>\n")
112 if rootSec.index != "" { 174 if root.index != "" {
113 navLink(&b, page{"Home", rootSec.index}, cur) 175 navlink(&b, page{"Home", root.index}, cur)
114 } 176 }
115 for _, p := range rootSec.pages { 177 for _, p := range root.pages {
116 navLink(&b, p, cur) 178 navlink(&b, p, cur)
117 } 179 }
118 for _, s := range subs { 180 for _, s := range subs {
119 link := s.index 181 link := s.index
120 if link == "" && len(s.pages) > 0 { 182 if link == "" && len(s.pages) > 0 {
121 link = s.pages[0].path 183 link = s.pages[0].path
122 } 184 }
123 if link == "" { 185 if link == "" {
124 continue 186 continue
125 } 187 }
126 navLink(&b, page{s.title, link}, cur) 188 navlink(&b, page{s.title, link}, cur)
127 } 189 }
128 b.WriteString("</ul>\n") 190 b.WriteString("</ul>\n")
129
130 // Find which sub-section cur belongs to
131 for _, s := range subs { 191 for _, s := range subs {
132 if !sectionContains(s, cur) { 192 if !sectioncontains(s, cur) {
133 continue 193 continue
134 } 194 }
135 // Only render sub-nav if there are 2+ addressable pages in the section
136 total := len(s.pages) 195 total := len(s.pages)
137 if s.index != "" { 196 if s.index != "" {
138 total++ 197 total++
139 } 198 }
140 if total < 2 { 199 if total < 2 {
141 break 200 break
142 } 201 }
143 b.WriteString("<ul>\n") 202 b.WriteString("<ul>\n")
144 if s.index != "" { 203 if s.index != "" {
145 navLink(&b, page{"Index", s.index}, cur) 204 navlink(&b, page{"Index", s.index}, cur)
146 } 205 }
147 for _, p := range s.pages { 206 for _, p := range s.pages {
148 navLink(&b, p, cur) 207 navlink(&b, p, cur)
149 } 208 }
150 b.WriteString("</ul>\n") 209 b.WriteString("</ul>\n")
151 break 210 break
152 } 211 }
153
154 return b.String() 212 return b.String()
155 } 213 }
156 214
157 func sectionContains(s section, cur string) bool { 215 func sectioncontains(s section, cur string) bool {
158 if s.index == cur { 216 if s.index == cur {
159 return true 217 return true
160 } 218 }
161 for _, p := range s.pages { 219 for _, p := range s.pages {
162 if p.path == cur { 220 if p.path == cur {
164 } 222 }
165 } 223 }
166 return false 224 return false
167 } 225 }
168 226
169 func mdToHTML(path string) (string, error) { 227 func mdtohtml(path string) (string, error) {
170 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml") 228 cmd := exec.Command("lowdown", "-T", "html", "--html-no-skiphtml", "--html-no-escapehtml")
171 f, err := os.Open(path) 229 f, err := os.Open(path)
172 if err != nil { 230 if err != nil {
173 return "", err 231 return "", err
174 } 232 }
181 return "", err 239 return "", err
182 } 240 }
183 return buf.String(), nil 241 return buf.String(), nil
184 } 242 }
185 243
186 func titleFromName(name string) string { 244 func titlefromname(name string) string {
187 name = strings.TrimSuffix(name, ".md") 245 name = strings.TrimSuffix(name, ".md")
188 name = strings.ReplaceAll(name, "-", " ") 246 name = strings.ReplaceAll(name, "-", " ")
189 if len(name) > 0 { 247 if len(name) > 0 {
190 name = strings.ToUpper(name[:1]) + name[1:] 248 name = strings.ToUpper(name[:1]) + name[1:]
191 } 249 }
192 return name 250 return name
193 } 251 }
194 252
195 func fixLinks(s string) string { 253 func fixlinks(s string) string {
196 return strings.NewReplacer( 254 return strings.NewReplacer(
197 ".md)", ".html)", ".md\"", ".html\"", 255 ".md)", ".html)",
198 ".md'", ".html'", ".md#", ".html#", 256 ".md\"", ".html\"",
199 ".md>", ".html>", ".md ", ".html ", 257 ".md'", ".html'",
258 ".md#", ".html#",
259 ".md>", ".html>",
260 ".md ", ".html ",
200 ".md,", ".html,", 261 ".md,", ".html,",
201 ).Replace(s) 262 ).Replace(s)
202 } 263 }
203 264
204 func copyFile(src, dst string) error { 265 func copyfile(src, dst string) error {
205 in, err := os.Open(src) 266 in, err := os.Open(src)
206 if err != nil { 267 if err != nil {
207 return err 268 return err
208 } 269 }
209 defer in.Close() 270 defer in.Close()
234 fmt.Fprintln(os.Stderr, err) 295 fmt.Fprintln(os.Stderr, err)
235 os.Exit(1) 296 os.Exit(1)
236 } 297 }
237 } 298 }
238 299
239 rootSec, subs := collectSections(src) 300 cfg := loadconfig(src)
301 tmpl := loadtemplate(cfg)
302 rootsec, subs := collectsections(src, cfg.headertext)
240 303
241 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { 304 err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
242 if err != nil { 305 if err != nil {
243 return err 306 return err
244 } 307 }
245 rel, _ := filepath.Rel(src, path) 308 rel, _ := filepath.Rel(src, path)
246 outpath := filepath.Join(out, rel) 309 outpath := filepath.Join(out, rel)
247
248 if d.IsDir() { 310 if d.IsDir() {
249 return os.MkdirAll(outpath, 0755) 311 return os.MkdirAll(outpath, 0755)
250 } 312 }
251
252 if !strings.HasSuffix(path, ".md") { 313 if !strings.HasSuffix(path, ".md") {
253 return copyFile(path, outpath) 314 return copyfile(path, outpath)
254 } 315 }
255 316 body, err := mdtohtml(path)
256 body, err := mdToHTML(path)
257 if err != nil { 317 if err != nil {
258 return err 318 return err
259 } 319 }
260 body = fixLinks(body) 320 body = fixlinks(body)
261
262 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html" 321 cur := "/" + strings.TrimSuffix(rel, ".md") + ".html"
263 322 title := cfg.headertext
264 title := siteTitle
265 if filepath.Base(path) != "index.md" { 323 if filepath.Base(path) != "index.md" {
266 title = siteTitle + " | " + titleFromName(filepath.Base(path)) 324 title = cfg.headertext + " | " + titlefromname(filepath.Base(path))
267 } 325 }
268 326 pg:= strings.ReplaceAll(tmpl, "{{TITLE}}", title)
269 pg := strings.ReplaceAll(tmpl, "{{TITLE}}", title) 327 pg = strings.ReplaceAll(pg, "{{SITE_TITLE}}", cfg.headertext)
270 pg = strings.ReplaceAll(pg, "{{NAV}}", buildNav(rootSec, subs, cur)) 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))
271 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body) 331 pg = strings.ReplaceAll(pg, "{{CONTENT}}", body)
272
273 outpath = strings.TrimSuffix(outpath, ".md") + ".html" 332 outpath = strings.TrimSuffix(outpath, ".md") + ".html"
274 return os.WriteFile(outpath, []byte(pg), 0644) 333 return os.WriteFile(outpath, []byte(pg), 0644)
275 }) 334 })
276 335
277 if err != nil { 336 if err != nil {