| 1 | package www |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "io/fs" |
| 8 | "math" |
| 9 | "strings" |
| 10 | |
| 11 | "amadan.net/rastrillo/fiesta" |
| 12 | ) |
| 13 | |
| 14 | // landingTemplate is the landing page's one template, under the site |
| 15 | // source. Every language renders from it with its own catalog. |
| 16 | const landingTemplate = "landing/page.html.tmpl" |
| 17 | |
| 18 | // page is what the template sees: the language and its direction for |
| 19 | // the html element, the site's Origin for a URL that has to be fully |
| 20 | // qualified, the version the footer shows, where the design system is |
| 21 | // in the page's language, the still bunting, every language the page |
| 22 | // is built in for the hreflang links and the language menu, and the |
| 23 | // catalog behind T. |
| 24 | type page struct { |
| 25 | Lang, Dir, Origin, Version string |
| 26 | DesignSystem string |
| 27 | Bunting template.HTML |
| 28 | Languages []menuLanguage |
| 29 | strings map[string]string |
| 30 | } |
| 31 | |
| 32 | // menuLanguage is one entry of the language menu and one hreflang |
| 33 | // link: a language's code, its name in its own language, where its |
| 34 | // page lives, and whether it is the page being rendered. |
| 35 | type menuLanguage struct { |
| 36 | Code, Name, URL string |
| 37 | Current bool |
| 38 | } |
| 39 | |
| 40 | // endonyms names each language in its own language, as Fiesta's |
| 41 | // locale-menu example does (examples/locale-menu/twelve-languages- |
| 42 | // current-highlighted.html), so the site's menu and Fiesta's agree. |
| 43 | // TestEveryPageLinksEveryLanguage holds the menu to the example. |
| 44 | var endonyms = map[string]string{ |
| 45 | "en": "English", |
| 46 | "ar": "العربية", |
| 47 | "bn": "বাংলা", |
| 48 | "es": "Español", |
| 49 | "ga": "Gaeilge", |
| 50 | "hi": "हिन्दी", |
| 51 | "ja": "日本語", |
| 52 | "pt-PT": "Português", |
| 53 | "ru": "Русский", |
| 54 | "vi": "Tiếng Việt", |
| 55 | "zh-Hans": "简体中文", |
| 56 | } |
| 57 | |
| 58 | // pagePath is where the landing page in lang lives in the tree: |
| 59 | // English at the root, every other language under its code, so each |
| 60 | // is a real page that a reader can bookmark and that needs no script. |
| 61 | func pagePath(lang string) string { |
| 62 | if lang == "en" { |
| 63 | return "index.html" |
| 64 | } |
| 65 | return lang + "/index.html" |
| 66 | } |
| 67 | |
| 68 | // pageURL is the same page's URL, a directory the edge serves its |
| 69 | // index from. |
| 70 | func pageURL(lang string) string { |
| 71 | if lang == "en" { |
| 72 | return "/" |
| 73 | } |
| 74 | return "/" + lang + "/" |
| 75 | } |
| 76 | |
| 77 | // designSystemURL is where the landing page in lang links the design |
| 78 | // system: its root for English, which is the default theme's English |
| 79 | // Overview, and the default theme's Overview in lang for every other |
| 80 | // language, so a reader arrives in the language they were reading. |
| 81 | func designSystemURL(lang string) string { |
| 82 | if lang == "en" { |
| 83 | return Mount + "/" |
| 84 | } |
| 85 | return Mount + "/" + fiesta.ThemeNames()[0] + "/" + lang + "/" |
| 86 | } |
| 87 | |
| 88 | // languages is the menu and hreflang list for the page in current, in |
| 89 | // Languages' order, with the current one marked. |
| 90 | func languages(current string) []menuLanguage { |
| 91 | out := make([]menuLanguage, 0, len(Languages)) |
| 92 | for _, l := range Languages { |
| 93 | out = append(out, menuLanguage{Code: l, Name: endonyms[l], URL: pageURL(l), Current: l == current}) |
| 94 | } |
| 95 | return out |
| 96 | } |
| 97 | |
| 98 | // buntingGeometry is the bunting's shape in the SVG's own units, one |
| 99 | // unit a pixel at scale 1: pennants FW wide at the top and FH to the |
| 100 | // tip, a lattice NY rows deep, hung Tie below a rope that starts TopY |
| 101 | // from the top, Pitch apart, Per to a swag with End clear at each |
| 102 | // tack, Swags swags across a row H tall. The rest are the cloth's |
| 103 | // physics: gravity G, velocity damping Damp, the cloth's Mass, Iter |
| 104 | // constraint passes a step of DT, a velocity cap VMax, the rope's |
| 105 | // inverse mass RopeInv, the bending stiffness Bend and the sewn tape's |
| 106 | // Hem, the rope's Slack per link, RopePasses extra passes over the |
| 107 | // rope, and the Hang in steps before the first frame. |
| 108 | // |
| 109 | // These are bunting.js's own numbers, so the still picture a reader |
| 110 | // gets with no JS or under reduced motion is the hang the canvas opens |
| 111 | // on: TestTheStillBuntingIsTheAnimations holds the geometry to the |
| 112 | // script's constants, and TestTheStillBuntingMatchesTheMovingOne holds |
| 113 | // the two pictures to each other. |
| 114 | type buntingGeometry struct { |
| 115 | FW, FH, NY, TopY, Tie, Pitch, Per, End, Swags, H int |
| 116 | G, Damp, Mass, DT, VMax, RopeInv, Bend, Hem, Slack float64 |
| 117 | Iter, RopePasses, Hang int |
| 118 | } |
| 119 | |
| 120 | var buntingShape = buntingGeometry{ |
| 121 | FW: 36, FH: 44, NY: 7, TopY: 4, Tie: 7, Pitch: 48, Per: 6, End: 34, Swags: 8, H: 96, |
| 122 | G: 650, Damp: 0.955, Mass: 1.15, DT: 1.0 / 60, VMax: 6, RopeInv: 0.25, Bend: 0.1, Hem: 0.7, Slack: 1.005, |
| 123 | Iter: 5, RopePasses: 6, Hang: 120, |
| 124 | } |
| 125 | |
| 126 | // pennantFills are the six cloths, in the order they hang. They live |
| 127 | // on the SVG as attributes, and bunting.js reads them from there, so |
| 128 | // site.css carries Fiesta's tokens only. |
| 129 | var pennantFills = []string{"#2257df", "#f2b632", "#e8553f", "#16a37f", "#7aa2ff", "#e84f8a"} |
| 130 | |
| 131 | // buntingRest is the bunting hung at rest: the rope's nodes in order, |
| 132 | // and for each pennant the rope nodes its two ties hang from and its |
| 133 | // two top corners. |
| 134 | type buntingRest struct { |
| 135 | ropeX, ropeY []float64 |
| 136 | flags []pennantRest |
| 137 | } |
| 138 | |
| 139 | type pennantRest struct { |
| 140 | tieL, tieR [2]float64 // the rope nodes the ties hang from |
| 141 | cornerL, cornerR [2]float64 // the cloth's top corners |
| 142 | } |
| 143 | |
| 144 | // rest hangs the bunting as bunting.js does before its first frame, |
| 145 | // with the same rope, ties and cloth lattices, the same Verlet step |
| 146 | // and the same constraint passes in the same order, Hang steps under |
| 147 | // gravity and nothing else. The script's hang is calm (no wind), so |
| 148 | // the two land on the same pose; it keeps x and y only, since with no |
| 149 | // wind nothing ever leaves the plane. |
| 150 | func (g buntingGeometry) rest() buntingRest { |
| 151 | span := g.Pitch*g.Per + 2*g.End |
| 152 | row := span * g.Swags |
| 153 | fw, tie, topY := float64(g.FW), float64(g.Tie), float64(g.TopY) |
| 154 | dx, dy := fw/float64(g.NY-1), float64(g.FH)/float64(g.NY-1) |
| 155 | dd := math.Hypot(dx/2, dy) |
| 156 | |
| 157 | // The rope, span by span: a tack, then for each pennant a node over |
| 158 | // each top corner and one between them, a node in each gap, and |
| 159 | // the closing tack. |
| 160 | var rx []float64 |
| 161 | var ties [][2]int |
| 162 | for s := 0; s < g.Swags; s++ { |
| 163 | x0 := float64(s * span) |
| 164 | rx = append(rx, x0, x0+float64(g.End)/2) |
| 165 | for p := 0; p < g.Per; p++ { |
| 166 | cx := x0 + float64(g.End+g.Pitch*p) + float64(g.Pitch)/2 |
| 167 | ties = append(ties, [2]int{len(rx), len(rx) + 2}) |
| 168 | rx = append(rx, cx-fw/2, cx, cx+fw/2) |
| 169 | if p < g.Per-1 { |
| 170 | rx = append(rx, cx+float64(g.Pitch)/2) |
| 171 | } else { |
| 172 | rx = append(rx, x0+float64(span)-float64(g.End)/2) |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | rx = append(rx, float64(row)) |
| 177 | ropeN := len(rx) |
| 178 | n := ropeN + len(ties)*g.NY*(g.NY+1)/2 |
| 179 | X, Y, PX, PY, INV := make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n), make([]float64, n) |
| 180 | // cons is a, b, length, stiffness, four per constraint, in the |
| 181 | // order the script solves them. |
| 182 | var cons []float64 |
| 183 | for i, x := range rx { |
| 184 | X[i], PX[i], Y[i], PY[i] = x, x, topY, topY |
| 185 | if math.Mod(x, float64(span)) != 0 { |
| 186 | INV[i] = g.RopeInv |
| 187 | } |
| 188 | if i > 0 { |
| 189 | cons = append(cons, float64(i-1), float64(i), (x-rx[i-1])*g.Slack, 1) |
| 190 | } |
| 191 | } |
| 192 | idx := ropeN |
| 193 | var corners [][2]int |
| 194 | for k, t := range ties { |
| 195 | cx := float64(g.End+(k%g.Per)*g.Pitch) + float64(g.Pitch)/2 + float64((k/g.Per)*span) |
| 196 | rows := make([][]int, g.NY) |
| 197 | for j := 0; j < g.NY; j++ { |
| 198 | nj, w := g.NY-j, fw-dx*float64(j) |
| 199 | for i := 0; i < nj; i++ { |
| 200 | X[idx], PX[idx] = cx-w/2+dx*float64(i), cx-w/2+dx*float64(i) |
| 201 | Y[idx], PY[idx] = topY+tie+dy*float64(j), topY+tie+dy*float64(j) |
| 202 | INV[idx] = 1 |
| 203 | rows[j] = append(rows[j], idx) |
| 204 | idx++ |
| 205 | } |
| 206 | } |
| 207 | cons = append(cons, float64(t[0]), float64(rows[0][0]), tie, 1, float64(t[1]), float64(rows[0][g.NY-1]), tie, 1, float64(rows[0][0]), float64(rows[0][g.NY-1]), fw, g.Hem) |
| 208 | for j := 0; j < g.NY; j++ { |
| 209 | rj := rows[j] |
| 210 | nj := len(rj) |
| 211 | for i := 0; i < nj; i++ { |
| 212 | a := float64(rj[i]) |
| 213 | if i < nj-1 { |
| 214 | cons = append(cons, a, float64(rj[i+1]), dx, 1) |
| 215 | } |
| 216 | if j < g.NY-1 { |
| 217 | rb := rows[j+1] |
| 218 | if i > 0 { |
| 219 | cons = append(cons, a, float64(rb[i-1]), dd, 1) |
| 220 | } |
| 221 | if i < len(rb) { |
| 222 | cons = append(cons, a, float64(rb[i]), dd, 1) |
| 223 | } |
| 224 | } |
| 225 | if j < g.NY-2 && i > 0 && i-1 < len(rows[j+2]) { |
| 226 | cons = append(cons, a, float64(rows[j+2][i-1]), 2*dy, g.Bend) |
| 227 | } |
| 228 | if i < nj-2 { |
| 229 | st := g.Bend |
| 230 | if j == 0 { |
| 231 | st = g.Hem |
| 232 | } |
| 233 | cons = append(cons, a, float64(rj[i+2]), 2*dx, st) |
| 234 | } |
| 235 | } |
| 236 | } |
| 237 | corners = append(corners, [2]int{rows[0][0], rows[0][g.NY-1]}) |
| 238 | } |
| 239 | |
| 240 | solve := func(a, b int, length, stiff float64) { |
| 241 | wa, wb := INV[a], INV[b] |
| 242 | ws := wa + wb |
| 243 | if ws == 0 { |
| 244 | return |
| 245 | } |
| 246 | ex, ey := X[b]-X[a], Y[b]-Y[a] |
| 247 | d := math.Sqrt(ex*ex + ey*ey) |
| 248 | if d == 0 { |
| 249 | d = 1e-6 |
| 250 | } |
| 251 | s := (d - length) / d * stiff / ws |
| 252 | X[a] += ex * s * wa |
| 253 | Y[a] += ey * s * wa |
| 254 | X[b] -= ex * s * wb |
| 255 | Y[b] -= ey * s * wb |
| 256 | } |
| 257 | dt2 := g.DT * g.DT |
| 258 | for step := 0; step < g.Hang; step++ { |
| 259 | for id := 0; id < n; id++ { |
| 260 | if INV[id] == 0 { |
| 261 | continue |
| 262 | } |
| 263 | vx, vy := (X[id]-PX[id])*g.Damp, (Y[id]-PY[id])*g.Damp |
| 264 | if v2 := vx*vx + vy*vy; v2 > g.VMax*g.VMax { |
| 265 | k := g.VMax / math.Sqrt(v2) |
| 266 | vx, vy = vx*k, vy*k |
| 267 | } |
| 268 | PX[id], PY[id] = X[id], Y[id] |
| 269 | X[id] += vx |
| 270 | Y[id] += vy + g.G*dt2 |
| 271 | } |
| 272 | for it := 0; it < g.Iter; it++ { |
| 273 | for k := 0; k < len(cons); k += 4 { |
| 274 | solve(int(cons[k]), int(cons[k+1]), cons[k+2], cons[k+3]) |
| 275 | } |
| 276 | for r := 0; r < g.RopePasses; r++ { |
| 277 | for k := 0; k < (ropeN-1)*4; k += 4 { |
| 278 | solve(int(cons[k]), int(cons[k+1]), cons[k+2], 1) |
| 279 | } |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | out := buntingRest{ropeX: X[:ropeN], ropeY: Y[:ropeN]} |
| 285 | for k, c := range corners { |
| 286 | t := ties[k] |
| 287 | out.flags = append(out.flags, pennantRest{ |
| 288 | tieL: [2]float64{X[t[0]], Y[t[0]]}, tieR: [2]float64{X[t[1]], Y[t[1]]}, |
| 289 | cornerL: [2]float64{X[c[0]], Y[c[0]]}, cornerR: [2]float64{X[c[1]], Y[c[1]]}, |
| 290 | }) |
| 291 | } |
| 292 | return out |
| 293 | } |
| 294 | |
| 295 | // stillBunting draws the bunting at rest as an SVG: one pennant |
| 296 | // definition per cloth, origin at the middle of its top edge, with a |
| 297 | // sewn tape and a stitch line along the top; the rope through every |
| 298 | // node of the hang; and each pennant's two ties and the pennant |
| 299 | // itself, translated to its top edge's middle and turned to its tilt. |
| 300 | // The classes are what site.css colours (the rope and ties are a |
| 301 | // Fiesta token) and what bunting.js reads. |
| 302 | func stillBunting(g buntingGeometry) template.HTML { |
| 303 | span := g.Pitch*g.Per + 2*g.End |
| 304 | half := float64(g.FW) / 2 |
| 305 | hang := g.rest() |
| 306 | var b strings.Builder |
| 307 | fmt.Fprintf(&b, `<svg class="site-flags" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMin slice" focusable="false">`+"\n<defs>\n", span*g.Swags, g.H) |
| 308 | for k, fill := range pennantFills { |
| 309 | fmt.Fprintf(&b, `<g id="site-p%d"><polygon fill="%s" points="%g,0 %g,0 0,%d"/><polygon fill="#000" opacity=".16" points="%g,0 %g,0 %.1f,3.2 %.1f,3.2"/><path fill="none" stroke="#fff" stroke-width=".35" stroke-dasharray=".9 .7" opacity=".45" d="M%.1f 3.6H%.1f"/></g>`+"\n", |
| 310 | k, fill, -half, half, g.FH, -half, half, half-1.3, -(half - 1.3), -(half - 1.5), half-1.5) |
| 311 | } |
| 312 | b.WriteString("</defs>\n") |
| 313 | b.WriteString(`<path class="site-span" d="`) |
| 314 | for i := range hang.ropeX { |
| 315 | if i == 0 { |
| 316 | fmt.Fprintf(&b, "M%.2f %.2f", hang.ropeX[i], hang.ropeY[i]) |
| 317 | } else { |
| 318 | fmt.Fprintf(&b, "L%.2f %.2f", hang.ropeX[i], hang.ropeY[i]) |
| 319 | } |
| 320 | } |
| 321 | b.WriteString("\"/>\n") |
| 322 | for k, f := range hang.flags { |
| 323 | fmt.Fprintf(&b, `<path class="site-tie" d="M%.2f %.2fL%.2f %.2fM%.2f %.2fL%.2f %.2f"/>`+"\n", |
| 324 | f.tieL[0], f.tieL[1], f.cornerL[0], f.cornerL[1], f.tieR[0], f.tieR[1], f.cornerR[0], f.cornerR[1]) |
| 325 | mx, my := (f.cornerL[0]+f.cornerR[0])/2, (f.cornerL[1]+f.cornerR[1])/2 |
| 326 | tilt := math.Atan2(f.cornerR[1]-f.cornerL[1], f.cornerR[0]-f.cornerL[0]) * 180 / math.Pi |
| 327 | fmt.Fprintf(&b, `<g class="site-flag" data-c="%d" transform="translate(%.2f %.2f) rotate(%.2f)"><use href="#site-p%d"/></g>`+"\n", k%len(pennantFills), mx, my, tilt, k%len(pennantFills)) |
| 328 | } |
| 329 | b.WriteString("</svg>") |
| 330 | return template.HTML(b.String()) |
| 331 | } |
| 332 | |
| 333 | // T is one catalog string by key. A key the catalog lacks is an error, |
| 334 | // so a typo in the template fails the render rather than rendering an |
| 335 | // empty element. |
| 336 | func (p page) T(key string) (string, error) { |
| 337 | s, ok := p.strings[key] |
| 338 | if !ok { |
| 339 | return "", fmt.Errorf("the landing page asks for %q, which the catalog does not have", key) |
| 340 | } |
| 341 | return s, nil |
| 342 | } |
| 343 | |
| 344 | // landing renders the landing page in lang from the site source: the |
| 345 | // template at landing/page.html.tmpl filled from Strings' catalog, with |
| 346 | // the version from fiesta.Version and every language's name and URL |
| 347 | // for the menu. html/template escapes every string for where it lands, |
| 348 | // so the install line's < reads as itself. A lang outside Languages |
| 349 | // has no page, and is refused before its catalog is looked for. |
| 350 | func landing(siteDir fs.FS, lang string) ([]byte, error) { |
| 351 | if _, ok := endonyms[lang]; !ok { |
| 352 | return nil, fmt.Errorf("www: %s is not a language the landing page is built in", lang) |
| 353 | } |
| 354 | strings, err := Strings(siteDir, lang) |
| 355 | if err != nil { |
| 356 | return nil, err |
| 357 | } |
| 358 | src, err := fs.ReadFile(siteDir, landingTemplate) |
| 359 | if err != nil { |
| 360 | return nil, fmt.Errorf("www: reading the landing page's template: %w", err) |
| 361 | } |
| 362 | tmpl, err := template.New(landingTemplate).Parse(string(src)) |
| 363 | if err != nil { |
| 364 | return nil, fmt.Errorf("www: %w", err) |
| 365 | } |
| 366 | var buf bytes.Buffer |
| 367 | if err := tmpl.Execute(&buf, page{Lang: lang, Dir: Dir(lang), Origin: Origin, Version: fiesta.Version, DesignSystem: designSystemURL(lang), Bunting: stillBunting(buntingShape), Languages: languages(lang), strings: strings}); err != nil { |
| 368 | return nil, fmt.Errorf("www: rendering the landing page in %s: %w", lang, err) |
| 369 | } |
| 370 | return buf.Bytes(), nil |
| 371 | } |
| 372 | |