nice clean recipes pear.dunkirk.sh
recipes
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: loading page

+322 -25
+1
go.mod
··· 5 5 require ( 6 6 github.com/aquilax/cooklang-go v0.2.0 7 7 github.com/go-chi/chi/v5 v5.2.5 8 + github.com/joho/godotenv v1.5.1 8 9 github.com/mattn/go-sqlite3 v1.14.42 9 10 golang.org/x/net v0.53.0 10 11 )
+2
go.sum
··· 2 2 github.com/aquilax/cooklang-go v0.2.0/go.mod h1:w8UlyehrdabhHxM1Qg+c6U0Scthpy8OOQbCEmtNyvTY= 3 3 github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= 4 4 github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 5 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 5 7 github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= 6 8 github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 7 9 golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+1 -1
internal/extract/pipeline.go
··· 75 75 return &Result{Recipe: recipe} 76 76 } 77 77 78 - return &Result{Error: fmt.Errorf("no recipe found on page — tried JSON-LD, microdata, and h-recipe extraction")} 78 + return &Result{Error: fmt.Errorf("no recipe found on page - tried JSON-LD, microdata, and h-recipe extraction")} 79 79 } 80 80 81 81 func (p *Pipeline) fetch(url string) (string, error) {
+172 -18
main.go
··· 4 4 "flag" 5 5 "fmt" 6 6 "html/template" 7 + "io/fs" 8 + "sync" 7 9 "log" 8 10 "net/http" 9 11 "net/url" 10 12 "strconv" 11 13 "strings" 12 - "io/fs" 14 + "time" 13 15 14 16 "github.com/go-chi/chi/v5" 15 17 "github.com/go-chi/chi/v5/middleware" 18 + "github.com/joho/godotenv" 16 19 17 20 "tangled.org/dunkirk.sh/pare/internal/cache" 18 21 "tangled.org/dunkirk.sh/pare/internal/cooklang" ··· 24 27 var gitHash = "dev" 25 28 26 29 func main() { 30 + godotenv.Load() 27 31 port := flag.Int("port", 3000, "port to listen on") 28 32 dbPath := flag.String("db", "pare.db", "path to SQLite database") 29 33 baseURL := flag.String("base-url", "", "base URL of this service") ··· 44 48 "isoToSeconds": isoToSeconds, 45 49 "cleanSource": cleanSource, 46 50 "renderStep": renderStep, 51 + "trimProto": func(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") }, 47 52 }).ParseFS(ui.Templates, "templates/*.html") 48 53 if err != nil { 49 54 log.Fatalf("parsing templates: %v", err) ··· 55 60 templates: tmpl, 56 61 baseURL: *baseURL, 57 62 gitHash: gitHash, 63 + pending: make(map[string]chan extractResult), 64 + failed: make(map[string]failedEntry), 58 65 } 59 66 60 67 r := chi.NewRouter() ··· 68 75 } 69 76 70 77 r.Get("/", srv.handleIndex) 71 - r.Get("/recipe", srv.handleRecipe) 72 78 r.Get("/export.cook", srv.handleCookExport) 79 + r.Get("/recipe", srv.handleRecipeQuery) 80 + r.Get("/status", srv.handleStatus) 81 + r.Get("/*", srv.handleRecipePath) 73 82 r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent)))) 74 83 75 84 addr := fmt.Sprintf(":%d", *port) ··· 80 89 } 81 90 82 91 type Server struct { 83 - pipeline *extract.Pipeline 84 - cache *cache.Cache 85 - templates *template.Template 86 - baseURL string 87 - gitHash string 92 + pipeline *extract.Pipeline 93 + cache *cache.Cache 94 + templates *template.Template 95 + baseURL string 96 + gitHash string 97 + pendingMu sync.Mutex 98 + pending map[string]chan extractResult 99 + failedMu sync.Mutex 100 + failed map[string]failedEntry 101 + } 102 + 103 + type failedEntry struct { 104 + msg string 105 + failedAt time.Time 106 + } 107 + 108 + type extractResult struct { 109 + recipe *models.Recipe 110 + err error 88 111 } 89 112 90 113 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 91 114 targetURL := r.URL.Query().Get("url") 92 115 if targetURL != "" { 93 - http.Redirect(w, r, "/recipe?url="+url.QueryEscape(targetURL), http.StatusFound) 116 + if strings.HasPrefix(targetURL, "https://") { 117 + targetURL = targetURL[8:] 118 + } else if strings.HasPrefix(targetURL, "http://") { 119 + targetURL = targetURL[7:] 120 + } 121 + http.Redirect(w, r, "/"+targetURL, http.StatusFound) 94 122 return 95 123 } 96 124 s.templates.ExecuteTemplate(w, "index_page", map[string]string{"GitHash": s.gitHash, "BaseURL": s.baseURL}) 97 125 } 98 126 99 - func (s *Server) handleRecipe(w http.ResponseWriter, r *http.Request) { 127 + func (s *Server) handleRecipeQuery(w http.ResponseWriter, r *http.Request) { 100 128 targetURL := r.URL.Query().Get("url") 101 129 if targetURL == "" { 102 130 http.Redirect(w, r, "/", http.StatusFound) 103 131 return 104 132 } 133 + if strings.HasPrefix(targetURL, "https://") { 134 + targetURL = targetURL[8:] 135 + } else if strings.HasPrefix(targetURL, "http://") { 136 + targetURL = targetURL[7:] 137 + } 138 + http.Redirect(w, r, "/"+targetURL, http.StatusMovedPermanently) 139 + } 105 140 106 - if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 107 - targetURL = "https://" + targetURL 141 + func (s *Server) handleRecipePath(w http.ResponseWriter, r *http.Request) { 142 + path := r.URL.Path 143 + if path == "/" || path == "" { 144 + http.Redirect(w, r, "/", http.StatusFound) 145 + return 108 146 } 109 147 148 + targetURL := "https://" + path[1:] 149 + 110 150 recipe, err := s.cache.Get(targetURL) 111 151 if err != nil { 112 152 log.Printf("cache read error: %v", err) 113 153 } 114 - if recipe == nil { 154 + if recipe != nil { 155 + s.renderRecipe(w, recipe, targetURL) 156 + return 157 + } 158 + 159 + // Check if extraction already failed (5min TTL) 160 + s.failedMu.Lock() 161 + entry, alreadyFailed := s.failed[targetURL] 162 + s.failedMu.Unlock() 163 + if alreadyFailed && time.Since(entry.failedAt) < 5*time.Minute { 164 + s.renderError(w, entry.msg, targetURL) 165 + return 166 + } 167 + if alreadyFailed { 168 + s.failedMu.Lock() 169 + delete(s.failed, targetURL) 170 + s.failedMu.Unlock() 171 + } 172 + 173 + // Not cached — start extraction if not already in flight 174 + s.startExtraction(targetURL) 175 + 176 + // Render loading interstitial 177 + data := map[string]interface{}{ 178 + "TargetURL": targetURL, 179 + "GitHash": s.gitHash, 180 + "BaseURL": s.baseURL, 181 + } 182 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 183 + s.templates.ExecuteTemplate(w, "loading_page", data) 184 + } 185 + 186 + func (s *Server) startExtraction(targetURL string) { 187 + s.pendingMu.Lock() 188 + defer s.pendingMu.Unlock() 189 + 190 + if _, ok := s.pending[targetURL]; ok { 191 + return 192 + } 193 + 194 + ch := make(chan extractResult, 1) 195 + s.pending[targetURL] = ch 196 + 197 + go func() { 115 198 result := s.pipeline.Extract(targetURL) 116 199 if result.Error != nil { 117 - s.renderError(w, result.Error.Error(), targetURL) 118 - return 200 + s.failedMu.Lock() 201 + s.failed[targetURL] = failedEntry{msg: result.Error.Error(), failedAt: time.Now()} 202 + s.failedMu.Unlock() 203 + 204 + ch <- extractResult{err: result.Error} 205 + } else { 206 + if err := s.cache.Set(targetURL, result.Recipe); err != nil { 207 + log.Printf("cache write error: %v", err) 208 + } 209 + ch <- extractResult{recipe: result.Recipe} 210 + } 211 + close(ch) 212 + 213 + s.pendingMu.Lock() 214 + delete(s.pending, targetURL) 215 + s.pendingMu.Unlock() 216 + }() 217 + } 218 + 219 + func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { 220 + targetURL := r.URL.Query().Get("url") 221 + if targetURL == "" { 222 + w.Header().Set("Content-Type", "application/json") 223 + w.Write([]byte(`{"ready":false,"error":"missing url"}`)) 224 + return 225 + } 226 + 227 + recipe, err := s.cache.Get(targetURL) 228 + if err != nil { 229 + log.Printf("cache read error: %v", err) 230 + } 231 + if recipe != nil { 232 + w.Header().Set("Content-Type", "application/json") 233 + w.Write([]byte(`{"ready":true}`)) 234 + return 235 + } 236 + 237 + s.pendingMu.Lock() 238 + ch, pending := s.pending[targetURL] 239 + s.pendingMu.Unlock() 240 + if !pending { 241 + // Check if it already failed (5min TTL) 242 + s.failedMu.Lock() 243 + entry, failed := s.failed[targetURL] 244 + s.failedMu.Unlock() 245 + if failed { 246 + if time.Since(entry.failedAt) < 5*time.Minute { 247 + errMsg := strings.ReplaceAll(entry.msg, `"`, `\\"`) 248 + w.Header().Set("Content-Type", "application/json") 249 + w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) 250 + return 251 + } 252 + s.failedMu.Lock() 253 + delete(s.failed, targetURL) 254 + s.failedMu.Unlock() 119 255 } 120 - recipe = result.Recipe 121 - if err := s.cache.Set(targetURL, recipe); err != nil { 122 - log.Printf("cache write error: %v", err) 256 + w.Header().Set("Content-Type", "application/json") 257 + w.Write([]byte(`{"ready":false,"error":"extraction not started"}`)) 258 + return 259 + } 260 + 261 + // Non-blocking read from channel 262 + select { 263 + case res := <-ch: 264 + w.Header().Set("Content-Type", "application/json") 265 + if res.err != nil { 266 + errMsg := strings.ReplaceAll(res.err.Error(), `"`, `\\"`) 267 + w.Write([]byte(fmt.Sprintf(`{"ready":false,"error":"%s"}`, errMsg))) 268 + } else { 269 + w.Write([]byte(`{"ready":true}`)) 123 270 } 271 + default: 272 + w.Header().Set("Content-Type", "application/json") 273 + w.Write([]byte(`{"ready":false}`)) 124 274 } 275 + } 125 276 277 + func (s *Server) renderRecipe(w http.ResponseWriter, recipe *models.Recipe, targetURL string) { 126 278 data := map[string]interface{}{ 127 279 "Recipe": recipe, 128 280 "TargetURL": targetURL, 129 281 "GitHash": s.gitHash, 130 282 "BaseURL": s.baseURL, 131 283 } 132 - 133 284 w.Header().Set("Content-Type", "text/html; charset=utf-8") 134 285 s.templates.ExecuteTemplate(w, "recipe_page", data) 135 286 } ··· 139 290 if targetURL == "" { 140 291 http.Error(w, "missing url parameter", http.StatusBadRequest) 141 292 return 293 + } 294 + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { 295 + targetURL = "https://" + targetURL 142 296 } 143 297 144 298 recipe, err := s.cache.Get(targetURL)
+42
ui/static/style.css
··· 341 341 color:var(--accent); 342 342 } 343 343 344 + .loading-box{ 345 + text-align:center; 346 + padding:4rem 1.5rem; 347 + } 348 + .loading-box h3{ 349 + font-family:'Poppins',system-ui,sans-serif; 350 + font-size:1.1rem; 351 + font-weight:600; 352 + color:var(--text); 353 + margin-bottom:0.5rem; 354 + } 355 + .loading-status{ 356 + font-size:0.9rem; 357 + color:var(--text-muted); 358 + margin-bottom:0.35rem; 359 + } 360 + .loading-source{ 361 + font-size:0.8rem; 362 + font-family:'Poppins',system-ui,sans-serif; 363 + } 364 + .loading-source a{ 365 + color:var(--text); 366 + text-decoration:none; 367 + } 368 + .loading-source a:hover{ 369 + color:var(--accent); 370 + text-decoration:underline; 371 + } 372 + .loading-source a:hover .source-path{ 373 + color:var(--accent); 374 + } 375 + .loading-spinner{ 376 + width:32px; 377 + height:32px; 378 + border:3px solid var(--border); 379 + border-top-color:var(--accent); 380 + border-radius:50%; 381 + margin:0 auto 1.5rem; 382 + animation:spin 0.8s linear infinite; 383 + } 384 + @keyframes spin{to{transform:rotate(360deg)}} 385 + 344 386 .bookmarklet{ 345 387 margin-top:2rem; 346 388 padding:1rem;
+17 -1
ui/templates/error.html
··· 14 14 </nav> 15 15 <div class="page"> 16 16 <div class="error-box"> 17 - <h3>Could not extract recipe</h3> 17 + <h3 id="error-title">Could not extract recipe</h3> 18 18 <p>{{.Error}}</p> 19 19 <p class="error-source">You can view the original page at <a href="{{.SourceURL}}" target="_blank" rel="noopener">{{with cleanSource .SourceURL}}{{.host}}{{if .path}}<span class="source-path">{{.path}}</span>{{end}}{{end}}</a></p> 20 20 </div> ··· 23 23 <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 24 24 <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 25 25 </footer> 26 + <script> 27 + const errorPhrases = [ 28 + "We burned the water", 29 + "The kitchen is on fire", 30 + "Someone forgot the baking soda", 31 + "The recipe vanished into thin air", 32 + "We dropped the souffle", 33 + "The dog ate the recipe", 34 + "Oops, all garnish", 35 + "Lost the recipe in the couch cushions", 36 + "Recipe 404: flavor not found", 37 + "The recipe is in another castle", 38 + ]; 39 + const title = document.getElementById('error-title'); 40 + title.textContent = errorPhrases[Math.floor(Math.random() * errorPhrases.length)]; 41 + </script> 26 42 </body> 27 43 </html> 28 44 {{end}}
+82
ui/templates/loading.html
··· 1 + {{define "loading_page"}} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="utf-8"> 6 + <meta name="viewport" content="width=device-width,initial-scale=1"> 7 + <title>pare - extracting…</title> 8 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 9 + <link rel="stylesheet" href="/static/style.css"> 10 + </head> 11 + <body> 12 + <nav> 13 + <a href="/" class="wordmark">pare</a> 14 + </nav> 15 + <div class="page"> 16 + <div class="loading-box" id="loading-container"> 17 + <div class="loading-spinner"></div> 18 + <h3 id="loading-title"></h3> 19 + <p class="loading-status" id="status-text"></p> 20 + <p class="loading-source" id="loading-source"><a href="{{.TargetURL}}" target="_blank" rel="noopener">{{with cleanSource .TargetURL}}{{.host}}{{if .path}}<span class="source-path">{{.path}}</span>{{end}}{{end}}</a></p> 21 + </div> 22 + </div> 23 + <footer> 24 + <span>made by <a href="https://dunkirk.sh" target="_blank" rel="noopener">Kieran Klukas</a></span> 25 + <a href="https://tangled.org/dunkirk.sh/pare/commit/{{.GitHash}}" target="_blank" rel="noopener" class="commit-link">{{.GitHash}}</a> 26 + </footer> 27 + <script> 28 + const targetURL = "{{.TargetURL | urlquery}}"; 29 + const statusText = document.getElementById('status-text'); 30 + const loadingTitle = document.getElementById('loading-title'); 31 + let elapsed = 0; 32 + 33 + const phrases = [ 34 + "Preheating the oven", 35 + "Gathering ingredients", 36 + "Chopping onions (no tears, promise)", 37 + "Whisking things together", 38 + "Simmering gently", 39 + "Zesting a lemon", 40 + "Rolling out the dough", 41 + "Reducing the sauce", 42 + "Tasting as we go", 43 + "Plating up", 44 + "Adding a pinch of salt", 45 + "Letting it rest", 46 + "Folding gently", 47 + "Checking the oven", 48 + "Garnishing with fresh herbs", 49 + ]; 50 + 51 + let phraseIdx = Math.floor(Math.random() * phrases.length); 52 + loadingTitle.textContent = phrases[phraseIdx] + '…'; 53 + 54 + const phraseInterval = setInterval(() => { 55 + phraseIdx = (phraseIdx + 1) % phrases.length; 56 + loadingTitle.textContent = phrases[phraseIdx] + '…'; 57 + }, 2000); 58 + 59 + function poll() { 60 + fetch('/status?url=' + targetURL) 61 + .then(r => r.json()) 62 + .then(data => { 63 + if (data.ready) { 64 + window.location.reload(); 65 + } else if (data.error) { 66 + clearInterval(phraseInterval); 67 + window.location.reload(); 68 + } else { 69 + elapsed += 1; 70 + setTimeout(poll, 1500); 71 + } 72 + }) 73 + .catch(() => { 74 + setTimeout(poll, 3000); 75 + }); 76 + } 77 + 78 + setTimeout(poll, 2000); 79 + </script> 80 + </body> 81 + </html> 82 + {{end}}
+5 -5
ui/templates/recipe.html
··· 9 9 <link rel="stylesheet" href="/static/style.css"> 10 10 <meta property="og:title" content="{{.Recipe.Name}}"> 11 11 <meta property="og:type" content="article"> 12 - <meta property="og:url" content="{{.BaseURL}}/recipe?url={{.TargetURL | urlquery}}"> 12 + <meta property="og:url" content="{{.BaseURL}}/{{trimProto .TargetURL}}"> 13 13 {{if .Recipe.Description}}<meta property="og:description" content="{{.Recipe.Description}}">{{end}} 14 14 {{if .Recipe.ImageURL}}<meta property="og:image" content="{{.Recipe.ImageURL}}">{{else}}<meta property="og:image" content="{{.BaseURL}}/static/og.png">{{end}} 15 15 <meta name="twitter:card" content="{{if .Recipe.ImageURL}}summary_large_image{{else}}summary{{end}}"> ··· 75 75 {{end}} 76 76 77 77 <div class="actions"> 78 - <a class="btn-primary" href="/export.cook?url={{.TargetURL | urlquery}}">Download .cook</a> 78 + <a class="btn-primary no-ext" href="/export.cook?url={{.TargetURL | urlquery}}">Download .cook</a> 79 79 </div> 80 80 81 81 <script> ··· 91 91 document.querySelectorAll('.ingredient-list li').forEach(li => { 92 92 state[li.id] = li.classList.contains('checked'); 93 93 }); 94 - localStorage.setItem('pare:' + new URL(location.href).searchParams.get('url'), JSON.stringify({t: Date.now(), s: state})); 94 + localStorage.setItem('pare:' + location.pathname.slice(1), JSON.stringify({t: Date.now(), s: state})); 95 95 } 96 96 97 97 function loadChecks() { 98 98 try { 99 - const raw = JSON.parse(localStorage.getItem('pare:' + new URL(location.href).searchParams.get('url')) || 'null'); 99 + const raw = JSON.parse(localStorage.getItem('pare:' + location.pathname.slice(1)) || 'null'); 100 100 if (!raw) return; 101 101 if (Date.now() - raw.t > 48 * 60 * 60 * 1000) { 102 - localStorage.removeItem('pare:' + new URL(location.href).searchParams.get('url')); 102 + localStorage.removeItem('pare:' + location.pathname.slice(1)); 103 103 return; 104 104 } 105 105 Object.entries(raw.s).forEach(([id, checked]) => {