Skip to content

Commit 8aa71da

Browse files
author
claude
committed
site: /for/{slug}/ pages redirect to live generator, retiring stale static pricing
The 195 /for/{slug}/ bundle pages were pre-pivot product artifacts: $7.99/mo pricing, '14-day trial' CTAs pointing at a /pricing/ page that no longer has a trial, SaaS-shaped offers that don't match the current desktop product. Every one was a factually wrong landing for visitors arriving via SEO. Fixing them individually would be 195 find-and-replace edits that still wouldn't match the live product's value proposition. This ships the alternative: retire the static pages entirely and redirect visitors to the live bundle generator with a pre-filled business description, producing an up-to-date result card that matches current product reality. Three moving parts: site/index.html — homepage ?q= URL parameter handler (and static/index.html via make site-sync) - On page load, reads ?q=<string> from the querystring - If present, length-valid (3-500 chars), pre-fills the input, auto-submits, and replaces the URL to /? so reload doesn't re-trigger and the sharable URL is clean - Wrapped in try/catch so ancient browsers without URLSearchParams just see the normal homepage - Reuses the existing submit() code path entirely — same render, counter bump, GA4 event, error handling, timeout, offline state internal/site/recommend.go — BundleQuery() helper + override map - BundleQuery(slug) returns (query, true) for the 195 known bundle slugs in bundles.json, defaulting to the bundle Name field ("Breweries & Distilleries" for brewery, "Therapists & Counselors" for therapist, etc.) - bundleQueryOverrides{} holds slug-specific overrides for slugs where the bundle Name produces ambiguous LLM output. Currently one entry: ark-rust → "ARK and Rust video game server administration" (without override, the LLM reads "Rust" as the programming language and produces a gamedev toolkit) internal/site/site.go — /for/{slug}/ route handler - orphanBundleSlugs{} identifies 3 /for/ dirs without bundles.json entries (self-hosters, solo-developers, startups) — pre-pivot artifacts that can't be routed to the generator - In the /for/ handler, before falling through to static file serve: if the slug matches a known bundle, 302 redirect to /?q=<url-encoded-query>. If it's an orphan, 302 to /desktop/. - Preserved as-is: the /for/ index page (canonical browse-all-bundles), /for/{slug}/install.sh (real endpoint for desktop install), AI-generated cache serve for unknown slugs Design choices: 302 not 301 during rollout. 301s cache in browsers indefinitely — a bug in the redirect would leave affected users stuck on bad redirects until they manually clear their cache. 302 is recoverable. Upgrade to 301 in a followup after confirming this has been running cleanly for a week. Pre-verification of all 195 slugs against live /api/recommend before shipping. Every slug produces ≥4 tools and a coherent title. The one exception (ark-rust) is handled by override. Audit data in /tmp/prewarm-results.jsonl (off-repo). Orphan slugs redirect to /desktop/ (the install page) rather than the homepage generator. The LLM can't coherently generate a toolkit for "self-hosters" — it isn't a business description. /desktop/ is the next-best destination (pricing + install CTAs they can act on). What's NOT in this change: - The underlying static site/for/{slug}/index.html files still exist. They're unreachable now for the 195 + 3 redirected slugs, but the files weren't deleted. Deletion is a separate followup (keeping them lets us flip the redirect off quickly if needed for debugging). - No test coverage for the redirect behavior. Existing site- package tests run against Register(mux, nil) where db==nil means recommender==nil, so the redirect branch doesn't fire in test context. Building a test helper that stands up a Recommender with in-memory DB is a worthwhile followup. Verification: - go build ./internal/site/...: clean - go vet ./internal/site/...: clean - go test ./internal/site/...: all existing tests pass (including TestNoDuplicateRoutes which catches accidental route conflicts) - Secret scan: 0 hits - URL encoding sanity-checked: 'Breweries & Distilleries' → /?q=Breweries+%26+Distilleries (correct Go url.QueryEscape output matches Python urllib quote_plus) Rollback if a bad redirect escapes the 195-slug audit: git revert HEAD && git push. ~170s redeploy. The 302 (not 301) means browsers won't hold the bad redirect after the revert.
1 parent d301968 commit 8aa71da

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

internal/site/recommend.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,44 @@ func (r *Recommender) loadBundles() {
318318
log.Printf("[recommend] loaded %d static bundles for install fallback", len(r.bundles))
319319
}
320320

321+
// bundleQueryOverrides maps slugs that need a custom natural-language
322+
// query to something different from their bundles.json Name field. Used
323+
// by BundleQuery for slugs where the bundle name produces ambiguous or
324+
// off-topic LLM output.
325+
//
326+
// `ark-rust`: the bundle Name is "ARK / Rust Server Admins". The LLM
327+
// reads "Rust" as the programming language and returns a dev toolkit
328+
// instead of a game-server toolkit. Override to disambiguate.
329+
//
330+
// Keep this map small. Most slugs should work with their bundle Name
331+
// verbatim. Add entries only with a verified before/after test.
332+
var bundleQueryOverrides = map[string]string{
333+
"ark-rust": "ARK and Rust video game server administration",
334+
}
335+
336+
// BundleQuery returns the natural-language query string to send to
337+
// /api/recommend for a given bundle slug, and whether the slug is a
338+
// known bundle. Returns (query, true) if the slug is in bundles.json
339+
// (the /for/{slug}/ redirect handler uses this to route visitors from
340+
// the static bundle pages to the live generator). Returns ("", false)
341+
// for unknown slugs.
342+
//
343+
// For most slugs, the query is the bundle's Name field from
344+
// bundles.json (e.g. "Breweries & Distilleries" for slug "brewery").
345+
// Slugs in bundleQueryOverrides get a custom query instead.
346+
func (r *Recommender) BundleQuery(slug string) (string, bool) {
347+
if override, ok := bundleQueryOverrides[slug]; ok {
348+
return override, true
349+
}
350+
r.mu.RLock()
351+
b, ok := r.bundles[slug]
352+
r.mu.RUnlock()
353+
if !ok || b == nil || b.Name == "" {
354+
return "", false
355+
}
356+
return b.Name, true
357+
}
358+
321359
// synthesizeResultForBundle builds a RecommendResult from a static
322360
// bundles.json entry. Used by GenerateInstallScript when the slug is a
323361
// known catalog bundle but has no LLM-cached result yet. The synthesized

internal/site/site.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"log"
1313
"net"
1414
"net/http"
15+
"net/url"
1516
"path"
1617
"strings"
1718
"sync"
@@ -21,6 +22,20 @@ import (
2122
//go:embed static
2223
var staticFiles embed.FS
2324

25+
// orphanBundleSlugs are /for/{slug}/ directories with static pages
26+
// from a pre-pivot product shape that have no matching bundle in
27+
// bundles.json. They exist as directories in site/for/ because the
28+
// pages were left in place, but the /for/ handler redirects them to
29+
// /desktop/ rather than serving the stale content or attempting to
30+
// run them through the bundle generator (which can't produce a
31+
// coherent result for "self-hosters" or "startups" — those aren't
32+
// business descriptions).
33+
var orphanBundleSlugs = map[string]struct{}{
34+
"self-hosters": {},
35+
"solo-developers": {},
36+
"startups": {},
37+
}
38+
2439
const affiliateSchema = `
2540
CREATE TABLE IF NOT EXISTS affiliates (
2641
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -389,6 +404,58 @@ func Register(mux *http.ServeMux, db *sql.DB) {
389404
} else {
390405
path = path + "/index.html"
391406
}
407+
408+
// Bundle slug redirect — if the path is /for/{slug}/ (or
409+
// /for/{slug}) and the slug matches a bundle in bundles.json,
410+
// 301 to the homepage with ?q=<natural-language-query>. This
411+
// retires the 195 static pages with stale pricing (pre-pivot
412+
// product shape, $7.99/mo, 14-day trial CTAs) in favor of a
413+
// live generator result that matches current product reality.
414+
//
415+
// The homepage /?q= handler (in site/index.html) auto-submits
416+
// the query and renders the result card inline. For visitors
417+
// whose LLM call fails, the homepage renders its offline state.
418+
//
419+
// The /for/ index page itself and /for/{slug}/install.sh are
420+
// intentionally NOT redirected — the index is still the
421+
// canonical browse-all-bundles page, and install.sh is a real
422+
// endpoint used by the desktop install flow.
423+
//
424+
// All 195 slugs were pre-verified against /api/recommend to
425+
// produce ≥4 tools and a coherent title. See BundleQuery in
426+
// recommend.go for the overrides table (currently: ark-rust).
427+
//
428+
// Orphan slugs (self-hosters, solo-developers, startups) are
429+
// pages from a pre-pivot product shape with no matching entry
430+
// in bundles.json. They redirect to /desktop/ — the generator
431+
// can't produce a bundle for "self-hosters" because it isn't a
432+
// business description.
433+
//
434+
// Using 302 (not 301) intentionally during initial rollout:
435+
// 301s cache in browsers indefinitely, making any bug in the
436+
// redirect logic effectively permanent for affected users
437+
// until they clear their cache. 302 gives us a recoverable
438+
// deploy. Upgrade to 301 after this has been running cleanly
439+
// for a week and we want the SEO benefit.
440+
if recommender != nil && strings.HasPrefix(path, "for/") && strings.HasSuffix(path, "/index.html") {
441+
slug := strings.TrimSuffix(strings.TrimPrefix(path, "for/"), "/index.html")
442+
if slug != "" && slug != "index.html" {
443+
if query, ok := recommender.BundleQuery(slug); ok {
444+
target := "/?q=" + url.QueryEscape(query)
445+
w.Header().Set("Cache-Control", "public, max-age=3600")
446+
http.Redirect(w, r, target, http.StatusFound)
447+
return
448+
}
449+
// Orphan slug — redirect to /desktop/ rather than
450+
// serve the stale static page.
451+
if _, orphan := orphanBundleSlugs[slug]; orphan {
452+
w.Header().Set("Cache-Control", "public, max-age=3600")
453+
http.Redirect(w, r, "/desktop/", http.StatusFound)
454+
return
455+
}
456+
}
457+
}
458+
392459
data, err := fs.ReadFile(sub, path)
393460
if err != nil && recommender != nil {
394461
// Try AI-generated cached page

internal/site/static/index.html

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,45 @@ <h4>Contact</h4>
10331033
// container mutation isn't worth the complexity — just call it from
10341034
// the .then path where we already render the result. See submit().
10351035
window.__stockyardBumpCounter = bumpCounter;
1036+
1037+
// URL query parameter handler — lets /for/{slug}/ redirect visitors
1038+
// to /?q=<natural-language-description> and get a live generator
1039+
// result instead of a stale static page.
1040+
//
1041+
// Shape: if the page loads with ?q=<something>, prefill the input
1042+
// and auto-submit. Everything downstream (fetch, render, error
1043+
// handling, counter) runs exactly as if the user typed it.
1044+
//
1045+
// Guards:
1046+
// - Same length bounds as manual submit (3..500).
1047+
// - strip leading/trailing whitespace.
1048+
// - Only runs on initial page load; subsequent navigation
1049+
// within the SPA doesn't retrigger.
1050+
// - History: replaceState to a clean URL after submit so the
1051+
// visitor can share a clean link and browser back doesn't
1052+
// re-trigger the auto-submit loop.
1053+
try {
1054+
var params = new URLSearchParams(window.location.search);
1055+
var qParam = params.get('q');
1056+
if (qParam){
1057+
qParam = qParam.trim();
1058+
if (qParam.length >= 3 && qParam.length <= 500){
1059+
input.value = qParam;
1060+
// Replace the URL to remove ?q= so refreshing doesn't re-trigger
1061+
// and so the shared URL is clean.
1062+
if (window.history && window.history.replaceState){
1063+
window.history.replaceState({}, '', window.location.pathname);
1064+
}
1065+
// Defer to next tick so the above render/counter setup completes.
1066+
setTimeout(function(){ submit(qParam); }, 0);
1067+
}
1068+
}
1069+
} catch (e) {
1070+
// URLSearchParams unavailable in ancient browsers. Not worth a
1071+
// polyfill for this feature — a visitor on IE11 can still use
1072+
// the homepage manually, they just won't get auto-submit.
1073+
console.warn('URL param handler skipped:', e);
1074+
}
10361075
})();
10371076
</script>
10381077

site/index.html

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,45 @@ <h4>Contact</h4>
10331033
// container mutation isn't worth the complexity — just call it from
10341034
// the .then path where we already render the result. See submit().
10351035
window.__stockyardBumpCounter = bumpCounter;
1036+
1037+
// URL query parameter handler — lets /for/{slug}/ redirect visitors
1038+
// to /?q=<natural-language-description> and get a live generator
1039+
// result instead of a stale static page.
1040+
//
1041+
// Shape: if the page loads with ?q=<something>, prefill the input
1042+
// and auto-submit. Everything downstream (fetch, render, error
1043+
// handling, counter) runs exactly as if the user typed it.
1044+
//
1045+
// Guards:
1046+
// - Same length bounds as manual submit (3..500).
1047+
// - strip leading/trailing whitespace.
1048+
// - Only runs on initial page load; subsequent navigation
1049+
// within the SPA doesn't retrigger.
1050+
// - History: replaceState to a clean URL after submit so the
1051+
// visitor can share a clean link and browser back doesn't
1052+
// re-trigger the auto-submit loop.
1053+
try {
1054+
var params = new URLSearchParams(window.location.search);
1055+
var qParam = params.get('q');
1056+
if (qParam){
1057+
qParam = qParam.trim();
1058+
if (qParam.length >= 3 && qParam.length <= 500){
1059+
input.value = qParam;
1060+
// Replace the URL to remove ?q= so refreshing doesn't re-trigger
1061+
// and so the shared URL is clean.
1062+
if (window.history && window.history.replaceState){
1063+
window.history.replaceState({}, '', window.location.pathname);
1064+
}
1065+
// Defer to next tick so the above render/counter setup completes.
1066+
setTimeout(function(){ submit(qParam); }, 0);
1067+
}
1068+
}
1069+
} catch (e) {
1070+
// URLSearchParams unavailable in ancient browsers. Not worth a
1071+
// polyfill for this feature — a visitor on IE11 can still use
1072+
// the homepage manually, they just won't get auto-submit.
1073+
console.warn('URL param handler skipped:', e);
1074+
}
10361075
})();
10371076
</script>
10381077

0 commit comments

Comments
 (0)