articles.go (6022B)
1 package web 2 3 import ( 4 "bytes" 5 "fmt" 6 "html" 7 "html/template" 8 "io/fs" 9 "net/http" 10 "net/url" 11 "path" 12 "regexp" 13 "sort" 14 "strings" 15 "time" 16 ) 17 18 type Article struct { 19 Slug string 20 Title string 21 Teaser template.HTML 22 Summary string 23 Contents template.HTML 24 Lang string 25 ExtraFiles map[string]string 26 } 27 28 type articleLibrary map[string]map[string]Article 29 30 var ( 31 h2Pattern = regexp.MustCompile(`(?is)<h2(?:\s[^>]*)?>(.*?)</h2>`) 32 teaserPattern = regexp.MustCompile(`(?is)<p(?:\s[^>]*)?\bid\s*=\s*["']teaser["'][^>]*>.*?</p>`) 33 paragraph = regexp.MustCompile(`(?is)<p(?:\s[^>]*)?>.*?</p>`) 34 tagPattern = regexp.MustCompile(`(?s)<[^>]*>`) 35 spacePattern = regexp.MustCompile(`\s+`) 36 localFileReferencePattern = regexp.MustCompile(`(?i)(?:src|href)\s*=\s*["'](/[^"'?#]+)["']`) 37 ) 38 39 var articleLocales = map[string]string{ 40 "en": "en", "fr": "fr", "it": "it", "ru": "ru", "tr": "tr", 41 "uk": "uk", "de": "de", "es": "es", "br": "pt", "pt": "pt", 42 } 43 44 var selectedArticleSlugs = map[string]bool{ 45 "free-sw": true, "thegnuproject": true, "initial-announcement": true, 46 "manifesto": true, "why-free": true, "free-doc": true, "selling": true, 47 "categories": true, "open-source-misses-the-point": true, "not-ipr": true, 48 "words-to-avoid": true, "right-to-read": true, "misinterpreting-copyright": true, 49 "why-copyleft": true, "pragmatic": true, "trivial-patent": true, 50 "can-you-trust": true, "javascript-trap": true, 51 "who-does-that-server-really-serve": true, "compromise": true, 52 } 53 54 func loadArticles() (articleLibrary, error) { 55 library := make(articleLibrary) 56 extraFiles, err := availableArticleData() 57 if err != nil { 58 return nil, err 59 } 60 languages, err := fs.ReadDir(Assets, "assets/articles") 61 if err != nil { 62 return nil, fmt.Errorf("read articles: %w", err) 63 } 64 for _, language := range languages { 65 sourceLang := language.Name() 66 locale, supported := articleLocales[sourceLang] 67 if !language.IsDir() || !supported { 68 continue 69 } 70 files, err := fs.ReadDir(Assets, path.Join("assets/articles", sourceLang)) 71 if err != nil { 72 return nil, err 73 } 74 library[locale] = make(map[string]Article) 75 for _, file := range files { 76 if file.IsDir() { 77 continue 78 } 79 contents, err := Assets.ReadFile(path.Join("assets/articles", sourceLang, file.Name())) 80 if err != nil { 81 return nil, err 82 } 83 slug := strings.TrimSuffix(file.Name(), path.Ext(file.Name())) 84 if !selectedArticleSlugs[slug] { 85 continue 86 } 87 article, ok := parseArticle(locale, slug, string(contents)) 88 if !ok { 89 continue 90 } 91 article.Contents, article.ExtraFiles = rewriteArticleData(article.Slug, article.Contents, extraFiles) 92 article.Teaser, _ = rewriteArticleData(article.Slug, article.Teaser, extraFiles) 93 library[locale][article.Slug] = article 94 } 95 } 96 return library, nil 97 } 98 99 func availableArticleData() (map[string]string, error) { 100 entries, err := fs.ReadDir(Assets, "assets/data") 101 if err != nil { 102 return nil, fmt.Errorf("read article data: %w", err) 103 } 104 files := make(map[string]string) 105 for _, entry := range entries { 106 if !entry.IsDir() { 107 files[entry.Name()] = path.Join("assets/data", entry.Name()) 108 } 109 } 110 return files, nil 111 } 112 113 func rewriteArticleData(slug string, contents template.HTML, available map[string]string) (template.HTML, map[string]string) { 114 raw := string(contents) 115 matches := localFileReferencePattern.FindAllStringSubmatchIndex(raw, -1) 116 linked := make(map[string]string) 117 var rewritten strings.Builder 118 last := 0 119 for _, match := range matches { 120 name := path.Base(raw[match[2]:match[3]]) 121 assetPath, ok := available[name] 122 if !ok { 123 continue 124 } 125 rewritten.WriteString(raw[last:match[2]]) 126 rewritten.WriteString(url.PathEscape(slug) + "/data/" + url.PathEscape(name)) 127 last = match[3] 128 linked[name] = assetPath 129 } 130 if last == 0 { 131 return contents, linked 132 } 133 rewritten.WriteString(raw[last:]) 134 return template.HTML(rewritten.String()), linked 135 } 136 137 func (a *App) articleData(w http.ResponseWriter, r *http.Request) { 138 lang, ok := a.blogLanguage(w, r) 139 if !ok { 140 return 141 } 142 article, exists := a.articles[lang][r.PathValue("article")] 143 if !exists { 144 a.renderError(w, r, http.StatusNotFound, lang, "Page not found", nil) 145 return 146 } 147 name := r.PathValue("file") 148 assetPath, exists := article.ExtraFiles[name] 149 if !exists { 150 a.renderError(w, r, http.StatusNotFound, lang, "Supplemental file not found", nil) 151 return 152 } 153 contents, err := Assets.ReadFile(assetPath) 154 if err != nil { 155 a.renderError(w, r, http.StatusInternalServerError, lang, "Internal error", err) 156 return 157 } 158 w.Header().Set("Cache-Control", "public, max-age=3600") 159 w.Header().Set("X-Content-Type-Options", "nosniff") 160 http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(contents)) 161 } 162 163 func parseArticle(lang, slug, contents string) (Article, bool) { 164 titleMatch := h2Pattern.FindStringSubmatch(contents) 165 if len(titleMatch) != 2 { 166 return Article{}, false 167 } 168 title := strings.TrimSpace(html.UnescapeString(spacePattern.ReplaceAllString(tagPattern.ReplaceAllString(titleMatch[1], ""), " "))) 169 if title == "" { 170 return Article{}, false 171 } 172 teaser := teaserPattern.FindString(contents) 173 if teaser == "" { 174 for _, candidate := range paragraph.FindAllString(contents, -1) { 175 if len(strings.TrimSpace(tagPattern.ReplaceAllString(candidate, ""))) >= 100 { 176 teaser = candidate 177 break 178 } 179 } 180 } 181 if teaser == "" { 182 return Article{}, false 183 } 184 summary := strings.TrimSpace(html.UnescapeString(spacePattern.ReplaceAllString(tagPattern.ReplaceAllString(teaser, ""), " "))) 185 return Article{ 186 Slug: slug, Title: title, Teaser: template.HTML(teaser), Summary: summary, 187 Contents: template.HTML(contents), Lang: lang, 188 }, true 189 } 190 191 func sortedArticles(articles map[string]Article) []Article { 192 result := make([]Article, 0, len(articles)) 193 for _, article := range articles { 194 result = append(result, article) 195 } 196 sort.Slice(result, func(i, j int) bool { return result[i].Title < result[j].Title }) 197 return result 198 }