github.com/powerman/golang-tools@v0.1.11-0.20220410185822-5ad214d8d803/blog/blog.go (about) 1 // Copyright 2013 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package blog implements a web server for articles written in present format. 6 package blog // import "github.com/powerman/golang-tools/blog" 7 8 import ( 9 "bytes" 10 "encoding/json" 11 "encoding/xml" 12 "fmt" 13 "html/template" 14 "log" 15 "net/http" 16 "os" 17 "path/filepath" 18 "regexp" 19 "sort" 20 "strings" 21 "time" 22 23 "github.com/powerman/golang-tools/blog/atom" 24 "github.com/powerman/golang-tools/present" 25 ) 26 27 var ( 28 validJSONPFunc = regexp.MustCompile(`(?i)^[a-z_][a-z0-9_.]*$`) 29 // used to serve relative paths when ServeLocalLinks is enabled. 30 golangOrgAbsLinkReplacer = strings.NewReplacer( 31 `href="https://golang.org/pkg`, `href="/pkg`, 32 `href="https://golang.org/cmd`, `href="/cmd`, 33 ) 34 ) 35 36 // Config specifies Server configuration values. 37 type Config struct { 38 ContentPath string // Relative or absolute location of article files and related content. 39 TemplatePath string // Relative or absolute location of template files. 40 41 BaseURL string // Absolute base URL (for permalinks; no trailing slash). 42 BasePath string // Base URL path relative to server root (no trailing slash). 43 GodocURL string // The base URL of godoc (for menu bar; no trailing slash). 44 Hostname string // Server host name, used for rendering ATOM feeds. 45 AnalyticsHTML template.HTML // Optional analytics HTML to insert at the beginning of <head>. 46 47 HomeArticles int // Articles to display on the home page. 48 FeedArticles int // Articles to include in Atom and JSON feeds. 49 FeedTitle string // The title of the Atom XML feed 50 51 PlayEnabled bool 52 ServeLocalLinks bool // rewrite golang.org/{pkg,cmd} links to host-less, relative paths. 53 } 54 55 // Doc represents an article adorned with presentation data. 56 type Doc struct { 57 *present.Doc 58 Permalink string // Canonical URL for this document. 59 Path string // Path relative to server root (including base). 60 HTML template.HTML // rendered article 61 62 Related []*Doc 63 Newer, Older *Doc 64 } 65 66 // Server implements an http.Handler that serves blog articles. 67 type Server struct { 68 cfg Config 69 docs []*Doc 70 redirects map[string]string 71 tags []string 72 docPaths map[string]*Doc // key is path without BasePath. 73 docTags map[string][]*Doc 74 template struct { 75 home, index, article, doc *template.Template 76 } 77 atomFeed []byte // pre-rendered Atom feed 78 jsonFeed []byte // pre-rendered JSON feed 79 content http.Handler 80 } 81 82 // NewServer constructs a new Server using the specified config. 83 func NewServer(cfg Config) (*Server, error) { 84 present.PlayEnabled = cfg.PlayEnabled 85 86 if notExist(cfg.TemplatePath) { 87 return nil, fmt.Errorf("template directory not found: %s", cfg.TemplatePath) 88 } 89 root := filepath.Join(cfg.TemplatePath, "root.tmpl") 90 parse := func(name string) (*template.Template, error) { 91 path := filepath.Join(cfg.TemplatePath, name) 92 if notExist(path) { 93 return nil, fmt.Errorf("template %s was not found in %s", name, cfg.TemplatePath) 94 } 95 t := template.New("").Funcs(funcMap) 96 return t.ParseFiles(root, path) 97 } 98 99 s := &Server{cfg: cfg} 100 101 // Parse templates. 102 var err error 103 s.template.home, err = parse("home.tmpl") 104 if err != nil { 105 return nil, err 106 } 107 s.template.index, err = parse("index.tmpl") 108 if err != nil { 109 return nil, err 110 } 111 s.template.article, err = parse("article.tmpl") 112 if err != nil { 113 return nil, err 114 } 115 p := present.Template().Funcs(funcMap) 116 s.template.doc, err = p.ParseFiles(filepath.Join(cfg.TemplatePath, "doc.tmpl")) 117 if err != nil { 118 return nil, err 119 } 120 121 // Load content. 122 content := filepath.Clean(cfg.ContentPath) 123 err = s.loadDocs(content) 124 if err != nil { 125 return nil, err 126 } 127 128 err = s.renderAtomFeed() 129 if err != nil { 130 return nil, err 131 } 132 133 err = s.renderJSONFeed() 134 if err != nil { 135 return nil, err 136 } 137 138 // Set up content file server. 139 s.content = http.StripPrefix(s.cfg.BasePath, http.FileServer(http.Dir(cfg.ContentPath))) 140 141 return s, nil 142 } 143 144 var funcMap = template.FuncMap{ 145 "sectioned": sectioned, 146 "authors": authors, 147 } 148 149 // sectioned returns true if the provided Doc contains more than one section. 150 // This is used to control whether to display the table of contents and headings. 151 func sectioned(d *present.Doc) bool { 152 return len(d.Sections) > 1 153 } 154 155 // authors returns a comma-separated list of author names. 156 func authors(authors []present.Author) string { 157 var b bytes.Buffer 158 last := len(authors) - 1 159 for i, a := range authors { 160 if i > 0 { 161 if i == last { 162 if len(authors) > 2 { 163 b.WriteString(",") 164 } 165 b.WriteString(" and ") 166 } else { 167 b.WriteString(", ") 168 } 169 } 170 b.WriteString(authorName(a)) 171 } 172 return b.String() 173 } 174 175 // authorName returns the first line of the Author text: the author's name. 176 func authorName(a present.Author) string { 177 el := a.TextElem() 178 if len(el) == 0 { 179 return "" 180 } 181 text, ok := el[0].(present.Text) 182 if !ok || len(text.Lines) == 0 { 183 return "" 184 } 185 return text.Lines[0] 186 } 187 188 // loadDocs reads all content from the provided file system root, renders all 189 // the articles it finds, adds them to the Server's docs field, computes the 190 // denormalized docPaths, docTags, and tags fields, and populates the various 191 // helper fields (Next, Previous, Related) for each Doc. 192 func (s *Server) loadDocs(root string) error { 193 // Read content into docs field. 194 const ext = ".article" 195 fn := func(p string, info os.FileInfo, err error) error { 196 if err != nil { 197 return err 198 } 199 200 if filepath.Ext(p) != ext { 201 return nil 202 } 203 f, err := os.Open(p) 204 if err != nil { 205 return err 206 } 207 defer f.Close() 208 d, err := present.Parse(f, p, 0) 209 if err != nil { 210 return err 211 } 212 var html bytes.Buffer 213 err = d.Render(&html, s.template.doc) 214 if err != nil { 215 return err 216 } 217 p = p[len(root) : len(p)-len(ext)] // trim root and extension 218 p = filepath.ToSlash(p) 219 s.docs = append(s.docs, &Doc{ 220 Doc: d, 221 Path: s.cfg.BasePath + p, 222 Permalink: s.cfg.BaseURL + p, 223 HTML: template.HTML(html.String()), 224 }) 225 return nil 226 } 227 err := filepath.Walk(root, fn) 228 if err != nil { 229 return err 230 } 231 sort.Sort(docsByTime(s.docs)) 232 233 // Pull out doc paths and tags and put in reverse-associating maps. 234 s.docPaths = make(map[string]*Doc) 235 s.docTags = make(map[string][]*Doc) 236 s.redirects = make(map[string]string) 237 for _, d := range s.docs { 238 s.docPaths[strings.TrimPrefix(d.Path, s.cfg.BasePath)] = d 239 for _, t := range d.Tags { 240 s.docTags[t] = append(s.docTags[t], d) 241 } 242 } 243 for _, d := range s.docs { 244 for _, old := range d.OldURL { 245 if !strings.HasPrefix(old, "/") { 246 old = "/" + old 247 } 248 if _, ok := s.docPaths[old]; ok { 249 return fmt.Errorf("redirect %s -> %s conflicts with document %s", old, d.Path, old) 250 } 251 if new, ok := s.redirects[old]; ok { 252 return fmt.Errorf("redirect %s -> %s conflicts with redirect %s -> %s", old, d.Path, old, new) 253 } 254 s.redirects[old] = d.Path 255 } 256 } 257 258 // Pull out unique sorted list of tags. 259 for t := range s.docTags { 260 s.tags = append(s.tags, t) 261 } 262 sort.Strings(s.tags) 263 264 // Set up presentation-related fields, Newer, Older, and Related. 265 for _, doc := range s.docs { 266 // Newer, Older: docs adjacent to doc 267 for i := range s.docs { 268 if s.docs[i] != doc { 269 continue 270 } 271 if i > 0 { 272 doc.Newer = s.docs[i-1] 273 } 274 if i+1 < len(s.docs) { 275 doc.Older = s.docs[i+1] 276 } 277 break 278 } 279 280 // Related: all docs that share tags with doc. 281 related := make(map[*Doc]bool) 282 for _, t := range doc.Tags { 283 for _, d := range s.docTags[t] { 284 if d != doc { 285 related[d] = true 286 } 287 } 288 } 289 for d := range related { 290 doc.Related = append(doc.Related, d) 291 } 292 sort.Sort(docsByTime(doc.Related)) 293 } 294 295 return nil 296 } 297 298 // renderAtomFeed generates an XML Atom feed and stores it in the Server's 299 // atomFeed field. 300 func (s *Server) renderAtomFeed() error { 301 var updated time.Time 302 if len(s.docs) > 0 { 303 updated = s.docs[0].Time 304 } 305 feed := atom.Feed{ 306 Title: s.cfg.FeedTitle, 307 ID: "tag:" + s.cfg.Hostname + ",2013:" + s.cfg.Hostname, 308 Updated: atom.Time(updated), 309 Link: []atom.Link{{ 310 Rel: "self", 311 Href: s.cfg.BaseURL + "/feed.atom", 312 }}, 313 } 314 for i, doc := range s.docs { 315 if i >= s.cfg.FeedArticles { 316 break 317 } 318 319 // Use original article path as ID in atom feed 320 // to avoid articles being treated as new when renamed. 321 idPath := doc.Path 322 if len(doc.OldURL) > 0 { 323 old := doc.OldURL[0] 324 if !strings.HasPrefix(old, "/") { 325 old = "/" + old 326 } 327 idPath = old 328 } 329 330 e := &atom.Entry{ 331 Title: doc.Title, 332 ID: feed.ID + idPath, 333 Link: []atom.Link{{ 334 Rel: "alternate", 335 Href: doc.Permalink, 336 }}, 337 Published: atom.Time(doc.Time), 338 Updated: atom.Time(doc.Time), 339 Summary: &atom.Text{ 340 Type: "html", 341 Body: summary(doc), 342 }, 343 Content: &atom.Text{ 344 Type: "html", 345 Body: string(doc.HTML), 346 }, 347 Author: &atom.Person{ 348 Name: authors(doc.Authors), 349 }, 350 } 351 feed.Entry = append(feed.Entry, e) 352 } 353 data, err := xml.Marshal(&feed) 354 if err != nil { 355 return err 356 } 357 s.atomFeed = data 358 return nil 359 } 360 361 type jsonItem struct { 362 Title string 363 Link string 364 Time time.Time 365 Summary string 366 Content string 367 Author string 368 } 369 370 // renderJSONFeed generates a JSON feed and stores it in the Server's jsonFeed 371 // field. 372 func (s *Server) renderJSONFeed() error { 373 var feed []jsonItem 374 for i, doc := range s.docs { 375 if i >= s.cfg.FeedArticles { 376 break 377 } 378 item := jsonItem{ 379 Title: doc.Title, 380 Link: doc.Permalink, 381 Time: doc.Time, 382 Summary: summary(doc), 383 Content: string(doc.HTML), 384 Author: authors(doc.Authors), 385 } 386 feed = append(feed, item) 387 } 388 data, err := json.Marshal(feed) 389 if err != nil { 390 return err 391 } 392 s.jsonFeed = data 393 return nil 394 } 395 396 // summary returns the first paragraph of text from the provided Doc. 397 func summary(d *Doc) string { 398 if len(d.Sections) == 0 { 399 return "" 400 } 401 for _, elem := range d.Sections[0].Elem { 402 text, ok := elem.(present.Text) 403 if !ok || text.Pre { 404 // skip everything but non-text elements 405 continue 406 } 407 var buf bytes.Buffer 408 for _, s := range text.Lines { 409 buf.WriteString(string(present.Style(s))) 410 buf.WriteByte('\n') 411 } 412 return buf.String() 413 } 414 return "" 415 } 416 417 // rootData encapsulates data destined for the root template. 418 type rootData struct { 419 Doc *Doc 420 BasePath string 421 GodocURL string 422 AnalyticsHTML template.HTML 423 Data interface{} 424 } 425 426 // ServeHTTP serves the front, index, and article pages 427 // as well as the ATOM and JSON feeds. 428 func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 429 var ( 430 d = rootData{ 431 BasePath: s.cfg.BasePath, 432 GodocURL: s.cfg.GodocURL, 433 AnalyticsHTML: s.cfg.AnalyticsHTML, 434 } 435 t *template.Template 436 ) 437 switch p := strings.TrimPrefix(r.URL.Path, s.cfg.BasePath); p { 438 case "/": 439 d.Data = s.docs 440 if len(s.docs) > s.cfg.HomeArticles { 441 d.Data = s.docs[:s.cfg.HomeArticles] 442 } 443 t = s.template.home 444 case "/index": 445 d.Data = s.docs 446 t = s.template.index 447 case "/feed.atom", "/feeds/posts/default": 448 w.Header().Set("Content-type", "application/atom+xml; charset=utf-8") 449 w.Write(s.atomFeed) 450 return 451 case "/.json": 452 if p := r.FormValue("jsonp"); validJSONPFunc.MatchString(p) { 453 w.Header().Set("Content-type", "application/javascript; charset=utf-8") 454 fmt.Fprintf(w, "%v(%s)", p, s.jsonFeed) 455 return 456 } 457 w.Header().Set("Content-type", "application/json; charset=utf-8") 458 w.Write(s.jsonFeed) 459 return 460 default: 461 if redir, ok := s.redirects[p]; ok { 462 http.Redirect(w, r, redir, http.StatusMovedPermanently) 463 return 464 } 465 doc, ok := s.docPaths[p] 466 if !ok { 467 // Not a doc; try to just serve static content. 468 s.content.ServeHTTP(w, r) 469 return 470 } 471 d.Doc = doc 472 t = s.template.article 473 } 474 var err error 475 if s.cfg.ServeLocalLinks { 476 var buf bytes.Buffer 477 err = t.ExecuteTemplate(&buf, "root", d) 478 if err != nil { 479 log.Println(err) 480 return 481 } 482 _, err = golangOrgAbsLinkReplacer.WriteString(w, buf.String()) 483 } else { 484 err = t.ExecuteTemplate(w, "root", d) 485 } 486 if err != nil { 487 log.Println(err) 488 } 489 } 490 491 // docsByTime implements sort.Interface, sorting Docs by their Time field. 492 type docsByTime []*Doc 493 494 func (s docsByTime) Len() int { return len(s) } 495 func (s docsByTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 496 func (s docsByTime) Less(i, j int) bool { return s[i].Time.After(s[j].Time) } 497 498 // notExist reports whether the path exists or not. 499 func notExist(path string) bool { 500 _, err := os.Stat(path) 501 return os.IsNotExist(err) 502 }