Skip to content

Commit 55c5664

Browse files
authored
fix(brand): match SSR default brand to the TS registry (#92)
61a2d7f changed DEFAULT_BRAND_ID in src/brand/registry.ts from 'bluesky' to 'coseeker' but touched no Go file, so defaultBrandID in brand.go stayed 'bluesky'. Both maps fall back to their own default for any hostname they do not list, so every unlisted host was server-rendered with Bluesky OG/Twitter metadata and Bluesky pre-hydration splash colors, then hydrated into CoSeeker - a flash of the wrong background and wrong link previews. The Go value was a correct mirror when brand.go was written (042cfa8, when both sides said 'bluesky'); it is an inherited upstream default that the intentional default-flip never reached, not a deliberate choice. - Set defaultBrandID to 'coseeker' - Add brand_test.go, which parses the TS sources and asserts the default, the brand id set, and the hostname map all agree, plus ResolveBrand behavior (port stripping, lowercasing, www variants, unlisted host). Verified by mutation: reverting the default, dropping a hostname, and adding a TS-only brand each fail the suite. - Point the three "mirrors ..." comments at the enforcing test - REVIEW_FOLLOWUPS.md: correct item 5, which described the pre-61a2d7f54 fallback; expand item 6 with the manifest design and its two constraints (BgDark/BgDim are derived via invertPalette, and webHost lacks the www variants) This is a mirror-checker, not a single source of truth: per-brand copy and splash colors are still hand-maintained and unchecked. See item 6.
1 parent df19ae0 commit 55c5664

5 files changed

Lines changed: 245 additions & 12 deletions

File tree

brands/REVIEW_FOLLOWUPS.md

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,23 +56,59 @@ only by a length check in `src/brand/boot.ts`. A brand author who lists feeds in
5656
another order silently mislabels feeds. Make it a named shape, e.g.
5757
`defaultFeeds: {discover, timeline, extra?[]}`, so the contract is structural.
5858

59-
## 5. Web hostname resolution silently falls back to `bluesky`
59+
## 5. Web hostname resolution silently falls back to the default brand
6060

6161
`src/brand/resolve.web.ts` - `HOSTNAME_TO_BRAND_ID` matches
6262
`window.location.hostname` verbatim. Any unlisted host (staging, preview URL,
6363
Lightsail default domain, `localhost` without `EXPO_PUBLIC_BRAND`) renders as
64-
Bluesky with the wrong PDS/feeds and no error surfaced. Consider a louder
65-
fallback in dev, and/or derive the map from each brand's `webHost`.
64+
the default brand with the wrong PDS/feeds and no error surfaced. Consider a
65+
louder fallback in dev, and/or derive the map from each brand's `webHost`.
66+
67+
> Updated: this item originally read "falls back to `bluesky`", which was true
68+
> when written. `61a2d7f54` changed `DEFAULT_BRAND_ID` to `coseeker`, so the
69+
> concern is now about the silence of the fallback, not which brand it lands on.
6670
6771
## 6. Hostname->brand map + brand identity duplicated across TS and Go
6872

6973
`src/brand/resolve.web.ts` <-> `bskyweb/cmd/bskyweb/brand.go`. The hostname
7074
mapping, per-brand metadata, and now the splash background/primary colors are
71-
hand-mirrored in two languages (the Go comments say "mirrors ..."). They will
72-
drift - add or rename a brand on the TS side and SSR OG tags / splash colors
73-
keep serving the old value. Emit a single JSON manifest from the TS brand
74-
registry at build time and have the Go layer read it, or add a test asserting
75-
the two maps have identical keys.
75+
hand-mirrored in two languages (the Go comments say "mirrors ...").
76+
77+
**This has already happened once.** `61a2d7f54` changed `DEFAULT_BRAND_ID` from
78+
`bluesky` to `coseeker` in `src/brand/registry.ts` and touched no Go file, so
79+
`defaultBrandID` in `brand.go` stayed `bluesky`. Every hostname absent from both
80+
maps was served Bluesky OG/Twitter cards and Bluesky pre-hydration splash colors
81+
and then hydrated into CoSeeker - a visible flash of the wrong background plus
82+
wrong link previews. Fixed by setting `defaultBrandID = "coseeker"`.
83+
84+
`bskyweb/cmd/bskyweb/brand_test.go` now parses the TS sources and asserts the
85+
default, the brand id set, and the hostname map all agree. That closes the
86+
regression but is still a mirror-checker, not a single source of truth: it only
87+
covers the three things it knows to compare, and per-brand copy (`Description`,
88+
`TwitterHandle`, `DefaultOGImage`, `AppleItunesApp`) and the splash colors
89+
remain hand-maintained and unchecked.
90+
91+
The durable fix is a generated manifest. The pieces are already in place:
92+
93+
- `brands/<id>/brand.js` is plain JS precisely so non-TS toolchains can read it
94+
(`app.config.js` does, at native build time) and already carries `webHost`.
95+
- The Dockerfile builds the web bundle in a pnpm stage *before* the Go stage and
96+
does `COPY --from=web-build /app/bskyweb ./bskyweb`, and `post-web-build.js`
97+
already writes into `bskyweb/`. A generated `bskyweb/brands.json` would flow
98+
through with no new build stages.
99+
- `bskyweb/static.go` and `templates.go` already use `//go:embed`.
100+
101+
Two constraints to design around:
102+
103+
- `BgDark`/`BgDim` are *derived*, not copied - they are the inverted
104+
`contrast_1000` of the dark ramps via `invertPalette` in `src/alf/themes.ts`.
105+
The generator has to run that derivation rather than read literals.
106+
- `webHost` is a single host, but the maps carry `www.` variants too. Either
107+
emit both from the generator or widen `brand.js` to a `hosts: string[]`.
108+
109+
Check the manifest in and have CI assert it is not stale, so a bare
110+
`go run ./cmd/bskyweb` still works without a Node toolchain. Once the Go side
111+
reads the manifest, `brand_test.go` can be deleted.
76112

77113
## 7. Trending is gated in three places despite a single-chokepoint comment
78114

bskyweb/cmd/bskyweb/brand.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,10 @@ var brands = map[string]Brand{
8686
},
8787
}
8888

89-
// hostnameToBrandID mirrors src/brand/resolve.web.ts. Add entries as
90-
// production hostnames come online. Anything not listed falls back to
91-
// the default brand.
89+
// hostnameToBrandID mirrors HOSTNAME_TO_BRAND_ID in src/brand/resolve.web.ts;
90+
// TestHostnameMapMatchesTypeScript enforces that the two agree. Add entries as
91+
// production hostnames come online. Anything not listed falls back to the
92+
// default brand.
9293
var hostnameToBrandID = map[string]string{
9394
"k4m2a.app": "k4m2a",
9495
"www.k4m2a.app": "k4m2a",
@@ -98,7 +99,10 @@ var hostnameToBrandID = map[string]string{
9899
"www.coseeker.com": "coseeker",
99100
}
100101

101-
const defaultBrandID = "bluesky"
102+
// defaultBrandID must match DEFAULT_BRAND_ID in src/brand/registry.ts. If the
103+
// two disagree, an unlisted host gets SSR metadata and splash colors for one
104+
// brand and then hydrates into another. See TestDefaultBrandMatchesTypeScript.
105+
const defaultBrandID = "coseeker"
102106

103107
// ResolveBrand picks a brand from a Host header. Strips the port,
104108
// lowercases, and falls back to the default brand on unknown hosts.

bskyweb/cmd/bskyweb/brand_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"regexp"
7+
"testing"
8+
)
9+
10+
/*
11+
* The brand identity in this package is hand-mirrored from the TypeScript
12+
* brand registry (see the "mirrors" comments on brand.go and
13+
* src/brand/resolve.web.ts). Nothing in either build enforces that, and the
14+
* two have already drifted once: the TS default was changed from 'bluesky'
15+
* to 'coseeker' in 61a2d7f54 without touching brand.go, so every unlisted
16+
* host served Bluesky OG/Twitter metadata and Bluesky splash colors and then
17+
* hydrated into CoSeeker.
18+
*
19+
* These tests parse the TS sources directly so the mirror cannot drift again
20+
* without a build failure. They are a stopgap: the durable fix is to generate
21+
* both sides from one manifest (brands/REVIEW_FOLLOWUPS.md item 6).
22+
*/
23+
24+
const (
25+
tsRegistryPath = "../../../src/brand/registry.ts"
26+
tsResolvePath = "../../../src/brand/resolve.web.ts"
27+
)
28+
29+
var (
30+
tsDefaultBrandRe = regexp.MustCompile(`export const DEFAULT_BRAND_ID\s*=\s*['"]([^'"]+)['"]`)
31+
tsBrandsBlockRe = regexp.MustCompile(`export const brands:\s*Record<string,\s*Brand>\s*=\s*\{([^}]*)\}`)
32+
tsBrandKeyRe = regexp.MustCompile(`(?m)^\s*(\w+)\s*,\s*$`)
33+
tsHostBlockRe = regexp.MustCompile(`const HOSTNAME_TO_BRAND_ID:\s*Record<string,\s*string>\s*=\s*\{([^}]*)\}`)
34+
tsHostEntryRe = regexp.MustCompile(`['"]([^'"]+)['"]\s*:\s*['"]([^'"]+)['"]`)
35+
)
36+
37+
// readTS reads one of the TypeScript brand sources relative to this package.
38+
func readTS(t *testing.T, path string) string {
39+
t.Helper()
40+
b, err := os.ReadFile(filepath.Clean(path))
41+
if err != nil {
42+
t.Fatalf("read %s: %v (these tests parse the TS brand registry; run them from the package dir)", path, err)
43+
}
44+
return string(b)
45+
}
46+
47+
// submatch applies re to src and returns capture group 1, failing the test
48+
// with a pointer at the source file if the shape no longer matches.
49+
func submatch(t *testing.T, re *regexp.Regexp, src, path, what string) string {
50+
t.Helper()
51+
m := re.FindStringSubmatch(src)
52+
if m == nil {
53+
t.Fatalf("could not find %s in %s - the declaration was probably reshaped; update the regex in brand_test.go", what, path)
54+
}
55+
return m[1]
56+
}
57+
58+
func TestDefaultBrandMatchesTypeScript(t *testing.T) {
59+
want := submatch(t, tsDefaultBrandRe, readTS(t, tsRegistryPath), tsRegistryPath, "DEFAULT_BRAND_ID")
60+
61+
if defaultBrandID != want {
62+
t.Errorf("defaultBrandID = %q, but DEFAULT_BRAND_ID in %s is %q.\n"+
63+
"An unlisted host would be server-rendered as %q and hydrate as %q: "+
64+
"wrong link-preview cards and a flash of the wrong splash background.",
65+
defaultBrandID, tsRegistryPath, want, defaultBrandID, want)
66+
}
67+
}
68+
69+
func TestDefaultBrandIsRegistered(t *testing.T) {
70+
if _, ok := brands[defaultBrandID]; !ok {
71+
t.Fatalf("defaultBrandID %q is not a key in brands; ResolveBrand would return a zero Brand "+
72+
"and the templates would render empty metadata", defaultBrandID)
73+
}
74+
}
75+
76+
func TestBrandIDsMatchTypeScript(t *testing.T) {
77+
src := readTS(t, tsRegistryPath)
78+
block := submatch(t, tsBrandsBlockRe, src, tsRegistryPath, "the brands record")
79+
80+
ts := map[string]bool{}
81+
for _, m := range tsBrandKeyRe.FindAllStringSubmatch(block, -1) {
82+
ts[m[1]] = true
83+
}
84+
if len(ts) == 0 {
85+
t.Fatalf("parsed zero brand ids out of %s; update the regex in brand_test.go", tsRegistryPath)
86+
}
87+
88+
for id := range ts {
89+
if _, ok := brands[id]; !ok {
90+
t.Errorf("brand %q is registered in %s but missing from brands in brand.go; "+
91+
"SSR metadata and splash colors for it would fall back to %q",
92+
id, tsRegistryPath, defaultBrandID)
93+
}
94+
}
95+
for id := range brands {
96+
if !ts[id] {
97+
t.Errorf("brand %q exists in brand.go but not in %s; it was probably renamed or removed on the TS side",
98+
id, tsRegistryPath)
99+
}
100+
}
101+
}
102+
103+
func TestHostnameMapMatchesTypeScript(t *testing.T) {
104+
src := readTS(t, tsResolvePath)
105+
block := submatch(t, tsHostBlockRe, src, tsResolvePath, "HOSTNAME_TO_BRAND_ID")
106+
107+
ts := map[string]string{}
108+
for _, m := range tsHostEntryRe.FindAllStringSubmatch(block, -1) {
109+
ts[m[1]] = m[2]
110+
}
111+
if len(ts) == 0 {
112+
t.Fatalf("parsed zero hostnames out of %s; update the regex in brand_test.go", tsResolvePath)
113+
}
114+
115+
for host, id := range ts {
116+
got, ok := hostnameToBrandID[host]
117+
if !ok {
118+
t.Errorf("hostname %q maps to %q in %s but is missing from hostnameToBrandID; "+
119+
"SSR would serve the %q brand for it", host, id, tsResolvePath, defaultBrandID)
120+
continue
121+
}
122+
if got != id {
123+
t.Errorf("hostname %q maps to %q in brand.go but %q in %s", host, got, id, tsResolvePath)
124+
}
125+
}
126+
for host, id := range hostnameToBrandID {
127+
if _, ok := ts[host]; !ok {
128+
t.Errorf("hostname %q maps to %q in brand.go but is missing from %s; "+
129+
"SSR and the hydrated bundle would disagree on it", host, id, tsResolvePath)
130+
}
131+
}
132+
}
133+
134+
func TestResolveBrand(t *testing.T) {
135+
tests := []struct {
136+
name string
137+
host string
138+
want string
139+
}{
140+
{
141+
name: "listed host",
142+
host: "coseeker.com",
143+
want: "coseeker",
144+
},
145+
{
146+
name: "www variant",
147+
host: "www.k4m2a.app",
148+
want: "k4m2a",
149+
},
150+
{
151+
name: "port is stripped",
152+
host: "mdparivaar.com:8100",
153+
want: "mdparivaar",
154+
},
155+
{
156+
name: "host is lowercased",
157+
host: "WWW.CoSeeker.com",
158+
want: "coseeker",
159+
},
160+
{
161+
name: "bluesky is reachable only when listed, not as a fallback",
162+
host: "bsky.app",
163+
want: defaultBrandID,
164+
},
165+
{
166+
name: "unlisted host falls back to the default brand",
167+
host: "staging.example.com",
168+
want: defaultBrandID,
169+
},
170+
{
171+
name: "empty host falls back to the default brand",
172+
host: "",
173+
want: defaultBrandID,
174+
},
175+
}
176+
177+
for _, tt := range tests {
178+
t.Run(tt.name, func(t *testing.T) {
179+
if got := ResolveBrand(tt.host).ID; got != tt.want {
180+
t.Errorf("ResolveBrand(%q).ID = %q, want %q", tt.host, got, tt.want)
181+
}
182+
})
183+
}
184+
}

src/brand/registry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export const brands: Record<string, Brand> = {
1515
mdparivaar,
1616
}
1717

18+
/**
19+
* Brand used for any hostname not listed in `resolve.web.ts`. Mirrored by
20+
* `defaultBrandID` in `bskyweb/cmd/bskyweb/brand.go` - if the two disagree,
21+
* unlisted hosts get SSR metadata and splash colors for one brand and then
22+
* hydrate into another. `brand_test.go` fails the Go build if they diverge.
23+
*/
1824
export const DEFAULT_BRAND_ID = 'coseeker'
1925

2026
export function getBrandById(id: string | undefined): Brand {

src/brand/resolve.web.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {type Brand} from './types'
55
* Map a hostname to a brand id. Add deployment hostnames here as new
66
* communities come online. Subdomains and root domains are matched
77
* verbatim; anything not listed falls back to the default brand.
8+
*
9+
* Mirrored by `hostnameToBrandID` in `bskyweb/cmd/bskyweb/brand.go` for SSR.
10+
* Keep both in sync - `brand_test.go` fails the Go build if they diverge.
811
*/
912
const HOSTNAME_TO_BRAND_ID: Record<string, string> = {
1013
'k4m2a.app': 'k4m2a',

0 commit comments

Comments
 (0)