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 "golang.org/x/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 | "golang.org/x/tools/blog/atom" |
24 | "golang.org/x/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 | } |
503 |
Members