├── k8s
├── _init.yml
├── gitlab-secret.sh
├── service.yml
├── ingress.yml
├── deploy.sh
└── deployment.yml
├── landingpage
├── img
│ ├── logo.png
│ ├── logo.pxm
│ ├── favicon.ico
│ ├── headline.png
│ └── headline@2x.png
├── gitreleases.css
├── script.js
├── index.html
└── tachyons.min.css
├── postcss.config.js
├── test
└── fixtures
│ ├── error_release_not_found_tag.json
│ ├── error_release_not_found_latest.json
│ ├── error_asset_not_found_tag.json
│ ├── error_asset_not_found_latest.json
│ ├── ok_asset_found_tag.json
│ ├── error_owner_not_found.json
│ ├── error_repo_not_found.json
│ └── ok_asset_found_latest.json
├── Dockerfile
├── .editorconfig
├── .gitignore
├── go.mod
├── LICENSE
├── Makefile
├── metrics.go
├── cache.go
├── README.md
├── main.go
├── go.sum
├── api.go
├── github_test.go
└── github.go
/k8s/_init.yml:
--------------------------------------------------------------------------------
1 | kind: Namespace
2 | apiVersion: v1
3 | metadata:
4 | name: gitreleases
5 |
--------------------------------------------------------------------------------
/landingpage/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweibel/gitreleases/HEAD/landingpage/img/logo.png
--------------------------------------------------------------------------------
/landingpage/img/logo.pxm:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweibel/gitreleases/HEAD/landingpage/img/logo.pxm
--------------------------------------------------------------------------------
/landingpage/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweibel/gitreleases/HEAD/landingpage/img/favicon.ico
--------------------------------------------------------------------------------
/landingpage/img/headline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweibel/gitreleases/HEAD/landingpage/img/headline.png
--------------------------------------------------------------------------------
/landingpage/img/headline@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mweibel/gitreleases/HEAD/landingpage/img/headline@2x.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('cssnano')({
4 | preset: 'default',
5 | }),
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/test/fixtures/error_release_not_found_tag.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "release": null
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/fixtures/error_release_not_found_latest.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "releases": {
5 | "nodes": []
6 | }
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/k8s/gitlab-secret.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | kubectl create secret docker-registry gitlab-auth --docker-server=https://registry.gitlab.com --docker-username=$USERNAME --docker-password=$PASSWORD --docker-email=$EMAIL
3 |
--------------------------------------------------------------------------------
/test/fixtures/error_asset_not_found_tag.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "release": {
5 | "releaseAssets": {
6 | "nodes": []
7 | }
8 | }
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/test/fixtures/error_asset_not_found_latest.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "releases": {
5 | "nodes": [
6 | {
7 | "releaseAssets": {
8 | "nodes": []
9 | }
10 | }
11 | ]
12 | }
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/k8s/service.yml:
--------------------------------------------------------------------------------
1 | kind: Service
2 | apiVersion: v1
3 | metadata:
4 | labels:
5 | app: gitreleases
6 | version: "{{TAG}}"
7 | name: gitreleases
8 | namespace: gitreleases
9 | spec:
10 | ports:
11 | - name: http
12 | port: 80
13 | targetPort: 8080
14 | selector:
15 | app: gitreleases
16 | version: "{{TAG}}"
17 |
--------------------------------------------------------------------------------
/test/fixtures/ok_asset_found_tag.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "release": {
5 | "releaseAssets": {
6 | "nodes": [
7 | {
8 | "downloadUrl": "https://example.com/testing/testing/releases/download/sometag/testing.zip"
9 | }
10 | ]
11 | }
12 | }
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Start by building the application.
2 | FROM golang:1.11 as build
3 |
4 | WORKDIR /src/gitreleases
5 |
6 | COPY go.mod .
7 | COPY go.sum .
8 |
9 | RUN go mod download
10 |
11 | COPY . .
12 |
13 | RUN make install-go-deps
14 | RUN make build
15 |
16 | # Now copy it into our base image.
17 | FROM gcr.io/distroless/base
18 |
19 | COPY --from=build /src/gitreleases/gitreleases /
20 | CMD ["/gitreleases"]
21 |
--------------------------------------------------------------------------------
/test/fixtures/error_owner_not_found.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": null
4 | },
5 | "errors": [
6 | {
7 | "type": "NOT_FOUND",
8 | "path": [
9 | "repository"
10 | ],
11 | "locations": [
12 | {
13 | "line": 2,
14 | "column": 3
15 | }
16 | ],
17 | "message": "Could not resolve to a User with the username 'testing'."
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/test/fixtures/error_repo_not_found.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": null
4 | },
5 | "errors": [
6 | {
7 | "type": "NOT_FOUND",
8 | "path": [
9 | "repository"
10 | ],
11 | "locations": [
12 | {
13 | "line": 2,
14 | "column": 3
15 | }
16 | ],
17 | "message": "Could not resolve to a Repository with the name 'testing'."
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | end_of_line = lf
9 | insert_final_newline = true
10 | charset = utf-8
11 | indent_size = 2
12 | indent_style = space
13 |
14 | [*.go]
15 | indent_style = tab
16 |
17 | # Tab indentation (no size specified)
18 | [Makefile]
19 | indent_style = tab
20 |
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, build with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # ignore secrets
15 | /k8s/secret.yml
16 |
17 | # built binary
18 | gitreleases
19 |
20 | # built landingpage assetes
21 | public/
22 | statik/
23 | landingpage/style.min.css
24 |
25 | # node
26 | node_modules/
27 |
28 | # other
29 | .envrc
30 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/mweibel/gitreleases
2 |
3 | require (
4 | github.com/go-stack/stack v1.8.0 // indirect
5 | github.com/gorilla/mux v1.7.0
6 | github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec
7 | github.com/mattn/go-colorable v0.1.1 // indirect
8 | github.com/mattn/go-isatty v0.0.6 // indirect
9 | github.com/prometheus/client_golang v0.9.2
10 | github.com/rakyll/statik v0.1.5
11 | github.com/shurcooL/githubv4 v0.0.0-20190119021625-d9689b595017
12 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
13 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421
14 | )
15 |
16 | go 1.13
17 |
--------------------------------------------------------------------------------
/test/fixtures/ok_asset_found_latest.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": {
3 | "repository": {
4 | "releases": {
5 | "nodes": [
6 | {
7 | "releaseAssets": {
8 | "totalCount": 0,
9 | "nodes": []
10 | }
11 | },
12 | {
13 | "releaseAssets": {
14 | "totalCount": 1,
15 | "nodes": [
16 | {
17 | "downloadUrl": "https://example.com/testing/testing/releases/download/latest/testing.zip"
18 | }
19 | ]
20 | }
21 | }
22 | ]
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/k8s/ingress.yml:
--------------------------------------------------------------------------------
1 | apiVersion: networking.k8s.io/v1
2 | kind: Ingress
3 | metadata:
4 | name: gitreleases
5 | annotations:
6 | kubernetes.io/tls-acme: "true"
7 | kubernetes.io/ingress.class: "nginx"
8 | cert-manager.io/cluster-issuer: "letsencrypt"
9 | namespace: gitreleases
10 | spec:
11 | tls:
12 | - hosts:
13 | - gitreleases.dev
14 | secretName: gitreleases-tls
15 | rules:
16 | - host: gitreleases.dev
17 | http:
18 | paths:
19 | - path: /
20 | pathType: Prefix
21 | backend:
22 | service:
23 | name: gitreleases
24 | port:
25 | name: http
26 |
--------------------------------------------------------------------------------
/k8s/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | #
3 | # green/blue deployment
4 | # From: https://www.ianlewis.org/en/bluegreen-deployments-kubernetes
5 | #
6 |
7 | DEPLOYMENT=$1
8 | # PREV_DEPLOYMENT=`kubectl get deployments -n gitreleases -o=jsonpath='{.items[0].metadata.name}'`
9 | cat k8s/deployment.yml | sed 's/{{TAG}}/'${DEPLOYMENT}'/g'| kubectl apply -f -
10 |
11 | # Wait until the Deployment is ready by checking the MinimumReplicasAvailable condition.
12 | READY=$(kubectl get deploy -n gitreleases gitreleases -o json | jq '.status.conditions[] | select(.reason == "MinimumReplicasAvailable") | .status' | tr -d '"')
13 |
14 | while [[ "$READY" != "True" ]]; do
15 | READY=$(kubectl get deploy -n gitreleases gitreleases -o json | jq '.status.conditions[] | select(.reason == "MinimumReplicasAvailable") | .status' | tr -d '"')
16 | sleep 5
17 | done
18 |
19 | cat k8s/service.yml | sed 's/{{TAG}}/'${DEPLOYMENT}'/g'| kubectl apply -f -
20 |
21 | # kubectl delete deployment -n gitreleases $PREV_DEPLOYMENT
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Michael Weibel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/landingpage/gitreleases.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --clr-primary: #4c93dc;
3 | --clr-light-accent: #a3cae5;
4 | --clr-light-shades: #f8f8f9;
5 | --clr-dark-accent: #a179aa;
6 | --clr-dark-shades: #3c3839;
7 | }
8 | .bg-light-accent {
9 | background-color: var(--clr-light-accent);
10 | }
11 | .bg-light-shades {
12 | background-color: var(--clr-light-shades);
13 | }
14 | .bg-dark-accent {
15 | background-color: var(--clr-dark-accent);
16 | }
17 | .bg-dark-shades {
18 | background-color: var(--clr-dark-shades);
19 | }
20 | .dark-accent {
21 | color: var(--clr-dark-accent);
22 | }
23 | .dark-shades {
24 | color: var(--clr-dark-shades);
25 | }
26 | .light-shades {
27 | color: var(--clr-light-shades);
28 | }
29 | .light-accent {
30 | color: var(--clr-light-accent);
31 | }
32 | .primary {
33 | color: var(--clr-primary);
34 | }
35 | .hover-dark-accent:hover {
36 | color: var(--clr-dark-accent);
37 | }
38 | .hover-dark-shades:hover {
39 | color: var(--clr-dark-shades);
40 | }
41 | .b--dark-shades {
42 | border-color: var(--clr-dark-shades);
43 | }
44 |
45 | .wb-bw {
46 | word-break: break-word;
47 | }
48 | .top-10px{
49 | top: 10px;
50 | }
51 |
52 | @media screen and (min-width: 120em) {
53 | .w-50-h {
54 | /* yah I know, ugly :) */
55 | width: 50% !important;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | TAG?=$(shell git rev-parse --short=8 HEAD)
2 | export TAG
3 |
4 | all: build-landingpage build
5 |
6 | install: install-go-deps install-npm
7 | install-go-deps:
8 | go get -u github.com/rakyll/statik
9 | install-npm:
10 | npm install --no-package-lock postcss-cli purgecss cssnano autoprefixer
11 |
12 | # -- Go related targets
13 | test:
14 | go test ./...
15 | build:
16 | go generate
17 | go build -ldflags "-X main.version=$(TAG)" -o gitreleases .
18 |
19 | # -- Docker build
20 | pack:
21 | docker build -t registry.gitlab.com/mweibel/gitreleases:$(TAG) .
22 | push:
23 | docker push registry.gitlab.com/mweibel/gitreleases:$(TAG)
24 | apply-secret:
25 | kubectl apply -f k8s/secret.yml
26 | deploy:
27 | ./k8s/deploy.sh $(TAG)
28 | ship: build-landingpage build test pack push deploy
29 |
30 | # -- Landingpage build
31 | build-landingpage: public/style.min.css index.html public/script.js public/img/headline.png public/img/headline@2x.png public/img/logo.png public/img/favicon.ico | public public/img
32 |
33 | public:
34 | mkdir -p $@
35 | public/img:
36 | mkdir -p $@
37 |
38 | public/style.min.css: style.css
39 | npx purgecss --css style.css --content landingpage/index.html landingpage/script.js --out .
40 | npx postcss style.css -o $@
41 | rm style.css
42 | style.css: landingpage/gitreleases.css landingpage/tachyons.min.css
43 | cat landingpage/gitreleases.css landingpage/tachyons.min.css > $@
44 |
45 | index.html:
46 | cat landingpage/index.html | sed 's/{{TAG}}/$(TAG)/g' > public/$@
47 | public/%.js: landingpage/%.js
48 | cp $< $@
49 | public/img/%.png: landingpage/img/%.png
50 | cp $< $@
51 | public/img/%.ico: landingpage/img/%.ico
52 | cp $< $@
53 |
54 | .PHONY: all install install-go-deps install-npm test build build-landingpage index.html pack push apply-secret deploy ship
55 |
--------------------------------------------------------------------------------
/metrics.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/prometheus/client_golang/prometheus/promhttp"
7 |
8 | "github.com/prometheus/client_golang/prometheus"
9 | )
10 |
11 | // These metrics are copied from the promhttp documentation for now.
12 | var (
13 | inFlightGauge = prometheus.NewGauge(prometheus.GaugeOpts{
14 | Name: "in_flight_requests",
15 | Help: "A gauge of requests currently being served by the wrapped handler.",
16 | })
17 |
18 | counter = prometheus.NewCounterVec(
19 | prometheus.CounterOpts{
20 | Name: "api_requests_total",
21 | Help: "A counter for requests to the wrapped handler.",
22 | },
23 | []string{"code", "method"},
24 | )
25 |
26 | // duration is partitioned by the HTTP method and handler. It uses custom
27 | // buckets based on the expected request duration.
28 | duration = prometheus.NewHistogramVec(
29 | prometheus.HistogramOpts{
30 | Name: "request_duration_seconds",
31 | Help: "A histogram of latencies for requests.",
32 | Buckets: []float64{.25, .5, 1, 2.5, 5, 10},
33 | },
34 | []string{"handler", "method"},
35 | )
36 |
37 | // responseSize is partitioned by the HTTP method and handler.
38 | responseSize = prometheus.NewHistogramVec(
39 | prometheus.HistogramOpts{
40 | Name: "response_size_bytes",
41 | Help: "A histogram of response sizes for requests.",
42 | Buckets: []float64{200, 500, 900, 1500},
43 | },
44 | []string{"code", "method"},
45 | )
46 | )
47 |
48 | func init() {
49 | prometheus.MustRegister(inFlightGauge, counter, duration, responseSize)
50 | }
51 |
52 | func addRequestMetrics(name string, h http.Handler) http.Handler {
53 | return promhttp.InstrumentHandlerInFlight(inFlightGauge,
54 | promhttp.InstrumentHandlerDuration(duration.MustCurryWith(prometheus.Labels{"handler": name}),
55 | promhttp.InstrumentHandlerCounter(counter,
56 | promhttp.InstrumentHandlerResponseSize(responseSize, h),
57 | ),
58 | ),
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/cache.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | type item struct {
9 | value string
10 | err error
11 | lastAccess int64
12 | }
13 |
14 | type Cacher interface {
15 | Put(k, v string, err error)
16 | Get(k string) (v string, err error)
17 | }
18 |
19 | type GitReleasesCache struct {
20 | items map[string]*item
21 | l sync.RWMutex
22 | }
23 |
24 | // NewCache is a simple cache with expiration time (TTL).
25 | //
26 | // I was a bit lazy so I took the code mostly from:
27 | // https://stackoverflow.com/questions/25484122/map-with-ttl-option-in-go
28 | //
29 | // I just changed the mutex to an RWMutex and added the possibility to do specify the ticking time.
30 | func NewCache(ln int, maxTTL int, tickInterval time.Duration) (m *GitReleasesCache) {
31 | m = &GitReleasesCache{items: make(map[string]*item, ln)}
32 | go func() {
33 | for now := range time.Tick(tickInterval) {
34 | m.l.Lock()
35 | for k, v := range m.items {
36 | if now.Unix()-v.lastAccess > int64(maxTTL) {
37 | delete(m.items, k)
38 | }
39 | }
40 | m.l.Unlock()
41 | }
42 | }()
43 | return
44 | }
45 |
46 | // Put adds `v` using the key `k` to the cache. Error is optional and can be used to store an error in the cache for faster error lookups.
47 | func (m *GitReleasesCache) Put(k, v string, err error) {
48 | m.l.Lock()
49 | it, ok := m.items[k]
50 | if !ok {
51 | it = &item{value: v, err: err}
52 | m.items[k] = it
53 | }
54 | it.lastAccess = time.Now().Unix()
55 | m.l.Unlock()
56 | }
57 |
58 | // Get retrieves by key `k` the value. If `err` is non nil, this probably means an error has been cached explicitely.
59 | //
60 | // A not cached value is indicated using an empty string for `v` and a nil error.
61 | func (m *GitReleasesCache) Get(k string) (v string, err error) {
62 | m.l.RLock()
63 | if it, ok := m.items[k]; ok {
64 | v = it.value
65 | err = it.err
66 | it.lastAccess = time.Now().Unix()
67 | }
68 | m.l.RUnlock()
69 | return
70 | }
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Git Releases
2 |
3 | Git Releases allows you to directly link to the assets of your latest release on GitHub.
4 |
5 | ## Deprecation notice
6 |
7 | gitreleases.dev will be shut down soon. Use [GitHub direct links](https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases) instead.
8 |
9 | ## Contributions
10 |
11 | For any significant change, please open an issue first to discuss it.
12 |
13 | Other than that, anyone is more than welcome to contribute.
14 |
15 | ### Instructions
16 |
17 | This project requires `go` 1.11.
18 |
19 | Run locally:
20 |
21 | ```bash
22 | # retrieve a personal access token from GitHub on http://github.com/settings/tokens
23 | $ METRICS_USERNAME=gitreleases METRICS_PASSWORD=gitreleases LISTEN_ADDR=":8080" GITHUB_TOKEN="$GITHUB_TOKEN" go run main.go github.go api.go metrics.go cache.go
24 | ```
25 |
26 | Please use `goimports` for formatting the code.
27 |
28 | To update anything on the landingpage:
29 | ```bash
30 | $ make install
31 | $ make public/style.min.css
32 | # that's a bit convoluted to use, but anyway
33 | $ cp public/style.min.css landingpage/style.min.css
34 |
35 | # Then open `landingpage/index.html` and start editing
36 | ```
37 |
38 | #### Building
39 |
40 | ```bash
41 | $ make install
42 | $ make
43 | ```
44 |
45 | #### Deployment
46 |
47 | Deployment is done using kubernetes. k8s files are in the `k8s` folder.
48 |
49 | ```bash
50 | # build, pack, deploy
51 | $ make ship
52 | ```
53 |
54 | ## GitHub API
55 |
56 | GitHub API Explorer: https://developer.github.com/v4/explorer/
57 |
58 | ### GET /gh/{owner}/{repo}/{tag}/{assetName}
59 |
60 | ```graphql
61 | {
62 | repository(owner: $owner, name: $repo) {
63 | release(tagName: $tag) {
64 | releaseAssets(name: $assetName, first:1) {
65 | nodes {
66 | downloadUrl
67 | }
68 | }
69 | }
70 | }
71 | }
72 | ```
73 |
74 | ### GET /gh/{owner}/{repo}/latest/{assetName}
75 |
76 | ```graphql
77 | {
78 | repository(owner: $owner, name: $repo) {
79 | releases(last: 1) {
80 | nodes {
81 | releaseAssets(name: $assetName, first:1) {
82 | nodes {
83 | downloadUrl
84 | }
85 | }
86 | }
87 | }
88 | }
89 | }
90 | ```
91 |
--------------------------------------------------------------------------------
/k8s/deployment.yml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: gitreleases
5 | namespace: gitreleases
6 | labels:
7 | app: gitreleases
8 | spec:
9 | replicas: 2
10 | # rolling updates ensures that during a deployment the minmum number of pods is allways in ready state.
11 | strategy:
12 | type: RollingUpdate
13 | rollingUpdate:
14 | maxSurge: 30%
15 | maxUnavailable: 25%
16 | selector:
17 | matchLabels:
18 | app: gitreleases
19 | template:
20 | metadata:
21 | labels:
22 | app: gitreleases
23 | version: "{{TAG}}"
24 | annotations:
25 | ad.datadoghq.com/gitreleases.check_names: '["prometheus"]'
26 | ad.datadoghq.com/gitreleases.init_configs: "[{}]"
27 | ad.datadoghq.com/gitreleases.instances: '[{"prometheus_url": "http://%%env_METRICS_USERNAME%%:%%env_METRICS_PASSWORD%%@%%host%%:%%port%%/metrics","namespace": "gitreleases","metrics": ["api_requests_*","go_*","in_flight_requests","process_*","promhttp_*","request_duration_*","response_size_*"]}]'
28 | spec:
29 | imagePullSecrets:
30 | - name: gitlab-auth
31 | # If possible, don't schedule 2 pods of the same app on the same node.
32 | affinity:
33 | podAntiAffinity:
34 | preferredDuringSchedulingIgnoredDuringExecution:
35 | - weight: 100
36 | podAffinityTerm:
37 | topologyKey: kubernetes.io/hostname
38 | labelSelector:
39 | matchExpressions:
40 | - key: app
41 | operator: In
42 | values:
43 | - gitreleases
44 | containers:
45 | - name: gitreleases
46 | image: "registry.gitlab.com/mweibel/gitreleases:{{TAG}}"
47 | imagePullPolicy: IfNotPresent
48 | ports:
49 | - containerPort: 8080
50 | livenessProbe:
51 | httpGet:
52 | path: /status
53 | port: 8080
54 | initialDelaySeconds: 30
55 | timeoutSeconds: 30
56 | env:
57 | - name: LISTEN_ADDR
58 | value: ":8080"
59 | - name: GITHUB_TOKEN
60 | valueFrom:
61 | secretKeyRef:
62 | name: gitreleases-secret
63 | key: githubToken
64 | - name: METRICS_USERNAME
65 | valueFrom:
66 | secretKeyRef:
67 | name: gitreleases-secret
68 | key: metricsUsername
69 | - name: METRICS_PASSWORD
70 | valueFrom:
71 | secretKeyRef:
72 | name: gitreleases-secret
73 | key: metricsPassword
74 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | //go:generate statik -src=./public
2 |
3 | package main
4 |
5 | import (
6 | "context"
7 | "net/http"
8 | "os"
9 | "os/signal"
10 | "syscall"
11 | "time"
12 |
13 | log "github.com/inconshreveable/log15"
14 | )
15 |
16 | const (
17 | githubGraphqlEndpoint = "https://api.github.com/graphql"
18 | )
19 |
20 | var (
21 | terminate = make(chan os.Signal, 1)
22 | version = "dev"
23 | )
24 |
25 | type gitreleasesEnv struct {
26 | addr string
27 | token string
28 | metricsUsername string
29 | metricsPassword string
30 | }
31 |
32 | func getEnv() gitreleasesEnv {
33 | addr := os.Getenv("LISTEN_ADDR")
34 | token := os.Getenv("GITHUB_TOKEN")
35 | metricsUsername := os.Getenv("METRICS_USERNAME")
36 | metricsPassword := os.Getenv("METRICS_PASSWORD")
37 |
38 | if addr == "" {
39 | panic("LISTEN_ADDR is required")
40 | }
41 | if token == "" {
42 | panic("GITHUB_TOKEN is required")
43 | }
44 | if metricsUsername == "" {
45 | panic("METRICS_USERNAME is required")
46 | }
47 | if metricsPassword == "" {
48 | panic("METRICS_PASSWORD is required")
49 | }
50 | return gitreleasesEnv{addr: addr, token: token, metricsUsername: metricsUsername, metricsPassword: metricsPassword}
51 | }
52 |
53 | func main() {
54 | logger := log.New("module", "gitrelases/main", "version", version)
55 | // FIXME: change to another format before deployment
56 | handler := log.StreamHandler(os.Stdout, log.TerminalFormat())
57 | logger.SetHandler(handler)
58 |
59 | logger.Info("Starting up application")
60 |
61 | env := getEnv()
62 |
63 | cacheTTL := 5 * time.Minute
64 | tickerInterval := 10 * time.Minute
65 | cache := NewCache(1000, int(cacheTTL.Seconds()), tickerInterval)
66 |
67 | httpClient := NewOauthClient(context.Background(), env.token)
68 | client := NewGitHubClient(githubGraphqlEndpoint, httpClient, cache, logger.New("module", "gitreleases/github"))
69 | apiServer := NewAPIServer(env.addr, env.metricsUsername, env.metricsPassword, version, client, logger.New("module", "gitreleases/api"))
70 |
71 | // Catch SIGINT and SIGTERM.
72 | signal.Notify(terminate, syscall.SIGINT, syscall.SIGTERM)
73 |
74 | go func() {
75 | err := apiServer.Start()
76 | if err == http.ErrServerClosed {
77 | logger.Info("server closed")
78 | } else {
79 | logger.Error("cannot start server", "err", err)
80 | terminate <- syscall.SIGABRT
81 | }
82 | }()
83 |
84 | // Wait for SIGINT or SIGTERM as stop signal (or SIGABRT if the server
85 | // could not be started).
86 | sig := <-terminate
87 | if sig == syscall.SIGABRT {
88 | return
89 | }
90 | logger.Info("termination signal received", "signal", sig.String())
91 |
92 | // Graceful shutdown of the HTTP server. Give 500ms to finish
93 | // current messages being handled.
94 | grace, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
95 | defer cancel()
96 | err := apiServer.Shutdown(grace)
97 | if err != nil {
98 | logger.Info("server shutdown with problems", "err", err)
99 | } else {
100 | logger.Info("server shut down properly", "err", err)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
3 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
4 | github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
5 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
6 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
7 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
8 | github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
9 | github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
10 | github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec h1:CGkYB1Q7DSsH/ku+to+foV4agt2F2miquaLUgF6L178=
11 | github.com/inconshreveable/log15 v0.0.0-20180818164646-67afb5ed74ec/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
12 | github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
13 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
14 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
15 | github.com/mattn/go-isatty v0.0.6 h1:SrwhHcpV4nWrMGdNcC2kXpMfcBVYGDuTArqyhocJgvA=
16 | github.com/mattn/go-isatty v0.0.6/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
17 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
18 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
19 | github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740=
20 | github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
21 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
22 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
23 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8=
24 | github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
25 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE=
26 | github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
27 | github.com/rakyll/statik v0.1.5 h1:Ly2UjURzxnsSYS0zI50fZ+srA+Fu7EbpV5hglvJvJG0=
28 | github.com/rakyll/statik v0.1.5/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
29 | github.com/shurcooL/githubv4 v0.0.0-20190119021625-d9689b595017 h1:7ykwAbXU6ZgqeMACeXEbN2ZQFPsI7fbtOEJLQPA+B/4=
30 | github.com/shurcooL/githubv4 v0.0.0-20190119021625-d9689b595017/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
31 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
32 | github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
33 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
34 | golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
35 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg=
36 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
37 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI=
38 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
39 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
40 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
41 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
42 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8=
43 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
45 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
46 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
47 |
--------------------------------------------------------------------------------
/api.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/gorilla/mux"
11 | log "github.com/inconshreveable/log15"
12 | "github.com/prometheus/client_golang/prometheus/promhttp"
13 | "github.com/rakyll/statik/fs"
14 |
15 | _ "github.com/mweibel/gitreleases/statik"
16 | )
17 |
18 | const requestTimeout = 2 * time.Second
19 |
20 | type apiServer struct {
21 | server *http.Server
22 | router *mux.Router
23 | githubClient *GithubClient
24 | logger log.Logger
25 | version string
26 | }
27 |
28 | // Start is starting the HTTP server.
29 | func (as *apiServer) Start() error {
30 | return as.server.ListenAndServe()
31 | }
32 |
33 | // Shutdown stops the HTTP server, possibly gracefully if an according context is provided.
34 | func (as *apiServer) Shutdown(ctx context.Context) error {
35 | return as.server.Shutdown(ctx)
36 | }
37 |
38 | // DownloadRelease fetches a release from GitHub according to parameters specified.
39 | func (as *apiServer) DownloadRelease(w http.ResponseWriter, r *http.Request) {
40 | reqLogger := as.logger.New("method", r.Method, "url", r.RequestURI)
41 | reqLogger.Info("fetching release URL")
42 |
43 | vars := mux.Vars(r)
44 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
45 | defer cancel()
46 |
47 | url, err := as.githubClient.FetchReleaseURL(ctx, vars["owner"], vars["repo"], vars["tag"], vars["assetName"])
48 | if ctx.Err() != nil {
49 | reqLogger.Error("error retrieving release URL", "err", err, "ctx error", ctx.Err())
50 | writeHTTPError(w, reqLogger, http.StatusBadGateway, "Bad Gateway")
51 | return
52 | }
53 | if err != nil {
54 | switch t := err.(type) {
55 | case GitHubError:
56 | if t.Type == TypeNotFound {
57 | reqLogger.Info("data not found", "err", t.WrappedError, "vars", vars)
58 | writeHTTPError(w, reqLogger, http.StatusNotFound, t.WrappedError.Error())
59 | return
60 | } else {
61 | reqLogger.Error("unhandled github error", "err", t.WrappedError, "vars", vars)
62 | writeHTTPError(w, reqLogger, http.StatusInternalServerError, "Internal Server Error")
63 | return
64 | }
65 | }
66 | reqLogger.Error("error retrieving release URL", "err", err, "vars", vars)
67 | writeHTTPError(w, reqLogger, http.StatusInternalServerError, err.Error())
68 | return
69 | }
70 |
71 | reqLogger.Info("found release URL", "url", url)
72 |
73 | w.Header().Set("Location", url)
74 | w.WriteHeader(http.StatusMovedPermanently)
75 | }
76 |
77 | func (as *apiServer) Status(w http.ResponseWriter, r *http.Request) {
78 | reqLogger := as.logger.New("method", r.Method, "url", r.RequestURI)
79 |
80 | out := struct {
81 | Version string `json:"version"`
82 | }{
83 | Version: version,
84 | }
85 | encoder := json.NewEncoder(w)
86 |
87 | w.WriteHeader(http.StatusOK)
88 |
89 | err := encoder.Encode(&out)
90 | if err != nil {
91 | reqLogger.Error("error encoding json", "err", err)
92 | }
93 | }
94 |
95 | func writeHTTPError(w http.ResponseWriter, logger log.Logger, statusCode int, message string) {
96 | w.WriteHeader(statusCode)
97 | if _, err := fmt.Fprintln(w, message); err != nil {
98 | logger.Crit("error writing response", "err", err)
99 | }
100 | }
101 |
102 | func basicAuth(username, password string, h http.Handler) http.Handler {
103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 | user, pass, _ := r.BasicAuth()
105 |
106 | if username != user || password != pass {
107 | w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
108 | http.Error(w, "Unauthorized.", http.StatusUnauthorized)
109 | return
110 | }
111 |
112 | h.ServeHTTP(w, r)
113 | })
114 | }
115 |
116 | // NewAPIServer encapsulates the start of the gitreleases HTTP server.
117 | func NewAPIServer(addr, metricsUsername, metricsPassword, version string, client *GithubClient, logger log.Logger) *apiServer {
118 | r := mux.NewRouter()
119 |
120 | as := apiServer{
121 | server: &http.Server{
122 | Addr: addr,
123 | Handler: r,
124 | ReadTimeout: 10 * time.Second,
125 | WriteTimeout: 10 * time.Second,
126 | MaxHeaderBytes: 1 << 20,
127 | },
128 | githubClient: client,
129 | logger: logger,
130 | version: version,
131 | }
132 |
133 | r.Handle("/gh/{owner}/{repo}/{tag}/{assetName}", addRequestMetrics("DownloadRelease",
134 | http.HandlerFunc(as.DownloadRelease))).Methods(http.MethodGet)
135 | r.Handle("/metrics", basicAuth(metricsUsername, metricsPassword, promhttp.Handler())).Methods(http.MethodGet)
136 | r.HandleFunc("/status", as.Status).Methods(http.MethodGet)
137 |
138 | statikFS, err := fs.New()
139 | if err != nil {
140 | panic(err)
141 | }
142 |
143 | r.PathPrefix("/").Methods(http.MethodGet).Handler(addRequestMetrics("StaticAssets", http.StripPrefix("/", http.FileServer(statikFS))))
144 |
145 | return &as
146 | }
147 |
--------------------------------------------------------------------------------
/github_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "net/http/httptest"
10 | "os"
11 | "path/filepath"
12 | "testing"
13 |
14 | log "github.com/inconshreveable/log15"
15 | )
16 |
17 | func discardLogger() log.Logger {
18 | logger := log.New()
19 | logger.SetHandler(log.DiscardHandler())
20 | return logger
21 | }
22 |
23 | type NoopCache struct{}
24 |
25 | func (nc *NoopCache) Put(k, v string, err error) {
26 | }
27 | func (nc *NoopCache) Get(k string) (v string, err error) {
28 | return "", nil
29 | }
30 |
31 | func testingHTTPClient(handler http.Handler) (*httptest.Server, func()) {
32 | s := httptest.NewServer(handler)
33 |
34 | return s, s.Close
35 | }
36 |
37 | var fetchReleaseURLResponsesLatest = map[string]struct {
38 | FileName string
39 | ReturnValue string
40 | ReturnError error
41 | }{
42 | "owner not found": {
43 | FileName: "error_owner_not_found.json",
44 | ReturnValue: "",
45 | ReturnError: errors.New("Could not resolve to a User with the username 'testing'."),
46 | },
47 | "repo not found": {
48 | FileName: "error_repo_not_found.json",
49 | ReturnValue: "",
50 | ReturnError: errors.New("Could not resolve to a Repository with the name 'testing'."),
51 | },
52 | "release not found": {
53 | FileName: "error_release_not_found_latest.json",
54 | ReturnValue: "",
55 | ReturnError: errReleaseNotFound,
56 | },
57 | "asset not found": {
58 | FileName: "error_asset_not_found_latest.json",
59 | ReturnValue: "",
60 | ReturnError: errAssetNotFound,
61 | },
62 | "asset found": {
63 | FileName: "ok_asset_found_latest.json",
64 | ReturnValue: "https://example.com/testing/testing/releases/download/latest/testing.zip",
65 | ReturnError: nil,
66 | },
67 | }
68 |
69 | func TestGithubClient_FetchReleaseURL_Latest(t *testing.T) {
70 | for name, data := range fetchReleaseURLResponsesLatest {
71 | t.Run(name, func(t *testing.T) {
72 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73 | file, err := os.Open(filepath.Join("test", "fixtures", data.FileName))
74 | if err != nil {
75 | panic(err)
76 | }
77 | _, err = io.Copy(w, file)
78 | if err != nil {
79 | panic(err)
80 | }
81 | })
82 | httpServer, teardown := testingHTTPClient(h)
83 | defer teardown()
84 |
85 | cache := NoopCache{}
86 | gh := NewGitHubClient(httpServer.URL, http.DefaultClient, &cache, discardLogger())
87 |
88 | url, err := gh.FetchReleaseURL(context.Background(), "testing", "testing", "latest", "testing.zip")
89 | if url != data.ReturnValue {
90 | t.Errorf("url does not match. Expected: '%s', got '%s'", data.ReturnValue, url)
91 | }
92 | if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", data.ReturnError) {
93 | t.Errorf("err does not match. Expected: '%v', got '%v'", data.ReturnError, err)
94 | }
95 | })
96 | }
97 | }
98 |
99 | var fetchReleaseURLResponsesTag = map[string]struct {
100 | FileName string
101 | ReturnValue string
102 | ReturnError error
103 | }{
104 | "owner not found": {
105 | FileName: "error_owner_not_found.json",
106 | ReturnValue: "",
107 | ReturnError: errors.New("Could not resolve to a User with the username 'testing'."),
108 | },
109 | "repo not found": {
110 | FileName: "error_repo_not_found.json",
111 | ReturnValue: "",
112 | ReturnError: errors.New("Could not resolve to a Repository with the name 'testing'."),
113 | },
114 | "release not found": {
115 | FileName: "error_release_not_found_tag.json",
116 | ReturnValue: "",
117 | ReturnError: errReleaseNotFound,
118 | },
119 | "asset not found": {
120 | FileName: "error_asset_not_found_tag.json",
121 | ReturnValue: "",
122 | ReturnError: errAssetNotFound,
123 | },
124 | "asset found": {
125 | FileName: "ok_asset_found_tag.json",
126 | ReturnValue: "https://example.com/testing/testing/releases/download/sometag/testing.zip",
127 | ReturnError: nil,
128 | },
129 | }
130 |
131 | func TestGithubClient_FetchReleaseURL_Tag(t *testing.T) {
132 | for name, data := range fetchReleaseURLResponsesTag {
133 | t.Run(name, func(t *testing.T) {
134 | h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 | file, err := os.Open(filepath.Join("test", "fixtures", data.FileName))
136 | if err != nil {
137 | panic(err)
138 | }
139 | _, err = io.Copy(w, file)
140 | if err != nil {
141 | panic(err)
142 | }
143 | })
144 | httpServer, teardown := testingHTTPClient(h)
145 | defer teardown()
146 |
147 | cache := NoopCache{}
148 | gh := NewGitHubClient(httpServer.URL, http.DefaultClient, &cache, discardLogger())
149 |
150 | url, err := gh.FetchReleaseURL(context.Background(), "testing", "testing", "sometag", "testing.zip")
151 | if url != data.ReturnValue {
152 | t.Errorf("url does not match. Expected: '%s', got '%s'", data.ReturnValue, url)
153 | }
154 | if fmt.Sprintf("%s", err) != fmt.Sprintf("%s", data.ReturnError) {
155 | t.Errorf("err does not match. Expected: '%v', got '%v'", data.ReturnError, err)
156 | }
157 | })
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/landingpage/script.js:
--------------------------------------------------------------------------------
1 | function removeAllChildren(el) {
2 | while (el.firstChild) {
3 | el.removeChild(el.firstChild);
4 | }
5 | }
6 |
7 | function filterReleases(response) {
8 | return response.slice(0, 5).find(function(release) {
9 | return release.assets && release.assets.length > 0;
10 | });
11 | }
12 |
13 | function createGitreleasesLink(path) {
14 | const a = document.createElement("a");
15 | a.className = "primary no-underline underline-hover";
16 | a.setAttribute("rel", "noopener");
17 | a.setAttribute("href", path);
18 | a.appendChild(document.createTextNode(`gitreleases.dev${path}`));
19 | return a;
20 | }
21 |
22 | function setButtonLoading(btn) {
23 | btn.className += " gray";
24 | btn.setAttribute("disabled", true);
25 | btn.children[0].className = "fa fa-spin fa-spinner";
26 | }
27 | function resetButton(btn) {
28 | btn.classList.remove("gray");
29 | btn.removeAttribute("disabled");
30 | btn.children[0].className = "fa fa-search";
31 | }
32 |
33 | function appendToListElement(el, list) {
34 | const li = document.createElement("li");
35 | li.appendChild(el);
36 | list.appendChild(li);
37 | }
38 |
39 | function createErrorNode(message) {
40 | const err = document.createElement("span");
41 | err.className = "red";
42 | err.appendChild(document.createTextNode(message));
43 | return err;
44 | }
45 |
46 | function setReleaseHeadingTitle(title, headingReleasesResult) {
47 | removeAllChildren(headingReleasesResult);
48 | headingReleasesResult.appendChild(document.createTextNode(title));
49 | }
50 |
51 | function onDocumentLoad() {
52 | const inputRepo = document.querySelector(".input-repo");
53 | const inputSubmit = document.querySelector(".input-submit");
54 | const ghReleasesSearch = document.querySelector(".gh-releases-search");
55 | const ghReleasesResult = document.querySelector(".gh-releases-results");
56 | const headingReleasesResult = document.querySelector(
57 | ".heading-releases-results"
58 | );
59 |
60 | const listener = function(event) {
61 | event.preventDefault();
62 | event.stopPropagation();
63 |
64 | const url = inputRepo.value;
65 | if (url.length === 0) {
66 | return;
67 | }
68 |
69 | const match = /(https?:\/\/github\.com\/)?([^/]+)\/([^/]+).*/i.exec(url);
70 | if (!match) {
71 | removeAllChildren(ghReleasesResult);
72 |
73 | const err = createErrorNode(
74 | "URL incorrect. Example: https://github.com/rokka-io/rokka-go"
75 | );
76 | appendToListElement(err, ghReleasesResult);
77 | url.value = "";
78 |
79 | return;
80 | }
81 |
82 | const organization = match[2];
83 | const repo = match[3];
84 |
85 | setButtonLoading(inputSubmit);
86 |
87 | const apiURL = `https://api.github.com/repos/${organization}/${repo}/releases`;
88 |
89 | fetch(apiURL, {
90 | method: "GET",
91 | headers: {
92 | Accept: "application/vnd.github.v3+json"
93 | }
94 | })
95 | .then(function(response) {
96 | if (!response.ok) {
97 | return Promise.reject(response);
98 | }
99 | return response.json();
100 | })
101 | .then(function(response) {
102 | if (!response.length) {
103 | return Promise.reject(new Error("No release found"));
104 | }
105 | const release = filterReleases(response);
106 | if (!release) {
107 | return null;
108 | }
109 | return release.assets;
110 | })
111 | .then(function(assets) {
112 | if (!assets || !assets.length) {
113 | return Promise.reject(new Error("No asset found"));
114 | }
115 |
116 | removeAllChildren(ghReleasesResult);
117 | setReleaseHeadingTitle("Available Asset URLs", headingReleasesResult);
118 |
119 | assets.forEach(function(asset) {
120 | const path = `/gh/${organization}/${repo}/latest/${asset.name}`;
121 | const li = document.createElement("li");
122 | li.className = "pb2";
123 | li.appendChild(createGitreleasesLink(path));
124 |
125 | ghReleasesResult.appendChild(li);
126 | });
127 | resetButton(inputSubmit);
128 | })
129 | .catch(function(error) {
130 | resetButton(inputSubmit);
131 | removeAllChildren(ghReleasesResult);
132 |
133 | setReleaseHeadingTitle("Error", headingReleasesResult);
134 |
135 | let message = error.statusText || error.message;
136 | if (message === "Forbidden") {
137 | message = `${message} - most likely means that you exceeded the hourly rate limit, sorry. Try constructing the URL on your own please :)`;
138 | } else if (message === "Not Found") {
139 | message = "Repository or organization not found";
140 | }
141 | appendToListElement(createErrorNode(message), ghReleasesResult);
142 | });
143 |
144 | return false;
145 | };
146 | ghReleasesSearch.addEventListener("submit", listener);
147 | inputRepo.addEventListener("blur", listener);
148 | }
149 |
150 | if (document.readyState === "loading") {
151 | document.addEventListener("DOMContentLoaded", onDocumentLoad);
152 | } else {
153 | onDocumentLoad();
154 | }
155 |
--------------------------------------------------------------------------------
/landingpage/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Git Releases - The missing link to your latest GitHub release asset
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
23 |
24 |
25 |
26 |
27 |
48 |
49 | The missing link to your latest GitHub release asset
50 |
51 |
52 |
53 |
54 |
55 | Releases on GitHub can have assets attached to them. You can link to
56 | each of them, however you can't link to the assets of the latest
57 | release. With a Git Releases link, you can skip updating your README
58 | or public website with a new download link whenever a new release
59 | has been done. Instead it will always point to the right location.
60 |
61 |
62 |
63 | Example
64 |
78 | Enter a GitHub repo
79 |
97 |
98 |
99 | URL format
100 |
101 |
gitreleases.dev/gh/{owner}/{repo}/{tag}/{assetname}
104 |
105 | {tag} can be a real tag or "latest".
106 |
107 |
108 |
109 |
110 |
111 |
122 |
128 |
129 |
130 |
131 |
148 |
149 |
150 |
151 |
--------------------------------------------------------------------------------
/github.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "strings"
9 | "time"
10 |
11 | log "github.com/inconshreveable/log15"
12 |
13 | "github.com/shurcooL/githubv4"
14 | "golang.org/x/oauth2"
15 | )
16 |
17 | type GithubClient struct {
18 | httpClient *http.Client
19 | cache Cacher
20 | client *githubv4.Client
21 | logger log.Logger
22 | }
23 |
24 | type rateLimit struct {
25 | Limit int
26 | Cost int
27 | Remaining int
28 | ResetAt time.Time
29 | }
30 |
31 | type releaseAssetNodes []struct {
32 | DownloadUrl string
33 | }
34 |
35 | type fetchSpecificTag struct {
36 | Repository struct {
37 | Release *struct {
38 | ReleaseAssets struct {
39 | Nodes releaseAssetNodes
40 | } `graphql:"releaseAssets(name: $assetName, first:1)"`
41 | } `graphql:"release(tagName: $tag)"`
42 | } `graphql:"repository(owner: $owner, name: $repo)"`
43 | RateLimit rateLimit
44 | }
45 | type fetchLatestRelease struct {
46 | Repository struct {
47 | Releases struct {
48 | Nodes []struct {
49 | ReleaseAssets struct {
50 | TotalCount int
51 | Nodes releaseAssetNodes
52 | } `graphql:"releaseAssets(name: $assetName, first:1)"`
53 | }
54 | } `graphql:"releases(first: 5, orderBy: {direction: DESC, field: CREATED_AT})"`
55 | } `graphql:"repository(owner: $owner, name: $repo)"`
56 | RateLimit rateLimit
57 | }
58 |
59 | type GitHubErrorType int
60 |
61 | const (
62 | TypeNotFound GitHubErrorType = iota
63 | TypeServerError
64 | )
65 |
66 | type GitHubError struct {
67 | WrappedError error
68 | Type GitHubErrorType
69 | }
70 |
71 | func (e GitHubError) Error() string {
72 | return e.WrappedError.Error()
73 | }
74 |
75 | func NewGitHubError(text string, t GitHubErrorType) GitHubError {
76 | return GitHubError{errors.New(text), t}
77 | }
78 |
79 | var (
80 | errReleaseNotFound = NewGitHubError("github: no release found", TypeNotFound)
81 | errAssetNotFound = NewGitHubError("github: asset not found", TypeNotFound)
82 | )
83 |
84 | // parseGraphqlError translates between the unfortunately opaque error type of the graphql library and our own.
85 | // Specific to GitHub errors and possible to fail if GitHub changes the error messages.
86 | func parseGraphqlError(err error) GitHubError {
87 | text := err.Error()
88 | if strings.Contains(text, "Could not resolve to") {
89 | return GitHubError{err, TypeNotFound}
90 | }
91 | return GitHubError{err, TypeServerError}
92 | }
93 |
94 | func (gh *GithubClient) fetchLatestRelease(ctx context.Context, owner, repo, assetName string) (releaseAssetNodes, rateLimit, error) {
95 | q := fetchLatestRelease{}
96 | variables := map[string]interface{}{
97 | "owner": githubv4.String(owner),
98 | "repo": githubv4.String(repo),
99 | "assetName": githubv4.String(assetName),
100 | }
101 |
102 | err := gh.client.Query(ctx, &q, variables)
103 | if err != nil {
104 | return nil, q.RateLimit, parseGraphqlError(err)
105 | }
106 |
107 | releases := q.Repository.Releases.Nodes
108 | if len(releases) == 0 {
109 | return nil, q.RateLimit, errReleaseNotFound
110 | }
111 | for _, node := range releases {
112 | if node.ReleaseAssets.TotalCount > 0 {
113 | return node.ReleaseAssets.Nodes, q.RateLimit, nil
114 | }
115 | }
116 |
117 | return nil, q.RateLimit, errAssetNotFound
118 | }
119 |
120 | func (gh *GithubClient) fetchSpecificTag(ctx context.Context, owner, repo, tag, assetName string) (releaseAssetNodes, rateLimit, error) {
121 | q := fetchSpecificTag{}
122 | variables := map[string]interface{}{
123 | "owner": githubv4.String(owner),
124 | "repo": githubv4.String(repo),
125 | "tag": githubv4.String(tag),
126 | "assetName": githubv4.String(assetName),
127 | }
128 |
129 | err := gh.client.Query(ctx, &q, variables)
130 | if err != nil {
131 | return nil, q.RateLimit, parseGraphqlError(err)
132 | }
133 |
134 | release := q.Repository.Release
135 | if release == nil {
136 | return nil, q.RateLimit, errReleaseNotFound
137 | }
138 | assets := release.ReleaseAssets.Nodes
139 |
140 | return assets, q.RateLimit, nil
141 | }
142 |
143 | // FetchReleaseURL decides based on the supplied `tag` which GraphQL query is executed.
144 | func (gh *GithubClient) FetchReleaseURL(ctx context.Context, owner, repo, tag, assetName string) (string, error) {
145 | var err error
146 |
147 | cacheKey := fmt.Sprintf("%s/%s/%s/%s", owner, repo, tag, assetName)
148 | cached, err := gh.cache.Get(cacheKey)
149 | if cached != "" || err != nil {
150 | return cached, err
151 | }
152 |
153 | var assets releaseAssetNodes
154 | var currLimit rateLimit
155 | if tag == "latest" {
156 | assets, currLimit, err = gh.fetchLatestRelease(ctx, owner, repo, assetName)
157 | } else {
158 | assets, currLimit, err = gh.fetchSpecificTag(ctx, owner, repo, tag, assetName)
159 | }
160 |
161 | if currLimit.Limit > 0 && currLimit.Remaining < 50 {
162 | gh.logger.Crit("almost no points remaining", "limit", currLimit.Limit, "cost", currLimit.Cost, "remaining", currLimit.Remaining, "resetAt", currLimit.ResetAt)
163 | } else {
164 | gh.logger.Info("current rate limit points", "limit", currLimit.Limit, "cost", currLimit.Cost, "remaining", currLimit.Remaining, "resetAt", currLimit.ResetAt)
165 | }
166 |
167 | if err != nil {
168 | gh.cache.Put(cacheKey, "", err)
169 | return "", err
170 | }
171 |
172 | if len(assets) == 0 {
173 | gh.cache.Put(cacheKey, "", errAssetNotFound)
174 | return "", errAssetNotFound
175 | }
176 |
177 | url := assets[0].DownloadUrl
178 | gh.cache.Put(cacheKey, url, nil)
179 |
180 | return url, nil
181 | }
182 |
183 | // NewOauthClient creates an oauth2 client with a static token source to use with GitHub's personal access tokens.
184 | func NewOauthClient(ctx context.Context, token string) *http.Client {
185 | src := oauth2.StaticTokenSource(
186 | &oauth2.Token{AccessToken: token},
187 | )
188 | return oauth2.NewClient(ctx, src)
189 | }
190 |
191 | // NewGitHubClient creates a GithubClient "enterprise" instance using an established oauth2 HTTP client.
192 | //
193 | // The url and httpClient are parameters mainly for proper testing purposes.
194 | func NewGitHubClient(url string, httpClient *http.Client, cache Cacher, logger log.Logger) *GithubClient {
195 | gc := GithubClient{
196 | httpClient: httpClient,
197 | cache: cache,
198 | logger: logger,
199 | }
200 |
201 | gc.client = githubv4.NewEnterpriseClient(url, gc.httpClient)
202 |
203 | return &gc
204 | }
205 |
--------------------------------------------------------------------------------
/landingpage/tachyons.min.css:
--------------------------------------------------------------------------------
1 | /*! TACHYONS v4.11.2 | http://tachyons.io */
2 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}.border-box,a,article,aside,blockquote,body,code,dd,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,html,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],legend,li,main,nav,ol,p,pre,section,table,td,textarea,th,tr,ul{box-sizing:border-box}.aspect-ratio{height:0;position:relative}.aspect-ratio--16x9{padding-bottom:56.25%}.aspect-ratio--9x16{padding-bottom:177.77%}.aspect-ratio--4x3{padding-bottom:75%}.aspect-ratio--3x4{padding-bottom:133.33%}.aspect-ratio--6x4{padding-bottom:66.6%}.aspect-ratio--4x6{padding-bottom:150%}.aspect-ratio--8x5{padding-bottom:62.5%}.aspect-ratio--5x8{padding-bottom:160%}.aspect-ratio--7x5{padding-bottom:71.42%}.aspect-ratio--5x7{padding-bottom:140%}.aspect-ratio--1x1{padding-bottom:100%}.aspect-ratio--object{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}.bg-center{background-position:50%}.bg-center,.bg-top{background-repeat:no-repeat}.bg-top{background-position:top}.bg-right{background-position:100%}.bg-bottom,.bg-right{background-repeat:no-repeat}.bg-bottom{background-position:bottom}.bg-left{background-repeat:no-repeat;background-position:0}.outline{outline:1px solid}.outline-transparent{outline:1px solid transparent}.outline-0{outline:0}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:hsla(0,0%,100%,.9)}.b--white-80{border-color:hsla(0,0%,100%,.8)}.b--white-70{border-color:hsla(0,0%,100%,.7)}.b--white-60{border-color:hsla(0,0%,100%,.6)}.b--white-50{border-color:hsla(0,0%,100%,.5)}.b--white-40{border-color:hsla(0,0%,100%,.4)}.b--white-30{border-color:hsla(0,0%,100%,.3)}.b--white-20{border-color:hsla(0,0%,100%,.2)}.b--white-10{border-color:hsla(0,0%,100%,.1)}.b--white-05{border-color:hsla(0,0%,100%,.05)}.b--white-025{border-color:hsla(0,0%,100%,.025)}.b--white-0125{border-color:hsla(0,0%,100%,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#357edd}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-right-radius:0}.br--right,.br--top{border-bottom-left-radius:0}.br--right{border-top-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}.shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}.cf:after,.cf:before{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}.flex{display:flex}.inline-flex{display:inline-flex}.flex-auto{flex:1 1 auto;min-width:0;min-height:0}.flex-none{flex:none}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.flex-column-reverse{flex-direction:column-reverse}.flex-row-reverse{flex-direction:row-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.self-baseline{align-self:baseline}.self-stretch{align-self:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.content-start{align-content:flex-start}.content-end{align-content:flex-end}.content-center{align-content:center}.content-between{align-content:space-between}.content-around{align-content:space-around}.content-stretch{align-content:stretch}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-last{order:99999}.flex-grow-0{flex-grow:0}.flex-grow-1{flex-grow:1}.flex-shrink-0{flex-shrink:0}.flex-shrink-1{flex-shrink:1}.fl{float:left}.fl,.fr{_display:inline}.fr{float:right}.fn{float:none}.sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:georgia,times,serif}.system-sans-serif{font-family:sans-serif}.system-serif{font-family:serif}.code,code{font-family:Consolas,monaco,monospace}.courier{font-family:Courier Next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:Bodoni MT,serif}.calisto{font-family:Calisto MT,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.i{font-style:italic}.fs-normal{font-style:normal}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}.link{text-decoration:none}.link,.link:active,.link:focus,.link:hover,.link:link,.link:visited{transition:color .15s ease-in}.link:focus{outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.rotate-45{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:hsla(0,0%,100%,.9)}.white-80{color:hsla(0,0%,100%,.8)}.white-70{color:hsla(0,0%,100%,.7)}.white-60{color:hsla(0,0%,100%,.6)}.white-50{color:hsla(0,0%,100%,.5)}.white-40{color:hsla(0,0%,100%,.4)}.white-30{color:hsla(0,0%,100%,.3)}.white-20{color:hsla(0,0%,100%,.2)}.white-10{color:hsla(0,0%,100%,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#357edd}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:hsla(0,0%,100%,.9)}.bg-white-80{background-color:hsla(0,0%,100%,.8)}.bg-white-70{background-color:hsla(0,0%,100%,.7)}.bg-white-60{background-color:hsla(0,0%,100%,.6)}.bg-white-50{background-color:hsla(0,0%,100%,.5)}.bg-white-40{background-color:hsla(0,0%,100%,.4)}.bg-white-30{background-color:hsla(0,0%,100%,.3)}.bg-white-20{background-color:hsla(0,0%,100%,.2)}.bg-white-10{background-color:hsla(0,0%,100%,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#357edd}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:focus,.hover-black:hover{color:#000}.hover-near-black:focus,.hover-near-black:hover{color:#111}.hover-dark-gray:focus,.hover-dark-gray:hover{color:#333}.hover-mid-gray:focus,.hover-mid-gray:hover{color:#555}.hover-gray:focus,.hover-gray:hover{color:#777}.hover-silver:focus,.hover-silver:hover{color:#999}.hover-light-silver:focus,.hover-light-silver:hover{color:#aaa}.hover-moon-gray:focus,.hover-moon-gray:hover{color:#ccc}.hover-light-gray:focus,.hover-light-gray:hover{color:#eee}.hover-near-white:focus,.hover-near-white:hover{color:#f4f4f4}.hover-white:focus,.hover-white:hover{color:#fff}.hover-black-90:focus,.hover-black-90:hover{color:rgba(0,0,0,.9)}.hover-black-80:focus,.hover-black-80:hover{color:rgba(0,0,0,.8)}.hover-black-70:focus,.hover-black-70:hover{color:rgba(0,0,0,.7)}.hover-black-60:focus,.hover-black-60:hover{color:rgba(0,0,0,.6)}.hover-black-50:focus,.hover-black-50:hover{color:rgba(0,0,0,.5)}.hover-black-40:focus,.hover-black-40:hover{color:rgba(0,0,0,.4)}.hover-black-30:focus,.hover-black-30:hover{color:rgba(0,0,0,.3)}.hover-black-20:focus,.hover-black-20:hover{color:rgba(0,0,0,.2)}.hover-black-10:focus,.hover-black-10:hover{color:rgba(0,0,0,.1)}.hover-white-90:focus,.hover-white-90:hover{color:hsla(0,0%,100%,.9)}.hover-white-80:focus,.hover-white-80:hover{color:hsla(0,0%,100%,.8)}.hover-white-70:focus,.hover-white-70:hover{color:hsla(0,0%,100%,.7)}.hover-white-60:focus,.hover-white-60:hover{color:hsla(0,0%,100%,.6)}.hover-white-50:focus,.hover-white-50:hover{color:hsla(0,0%,100%,.5)}.hover-white-40:focus,.hover-white-40:hover{color:hsla(0,0%,100%,.4)}.hover-white-30:focus,.hover-white-30:hover{color:hsla(0,0%,100%,.3)}.hover-white-20:focus,.hover-white-20:hover{color:hsla(0,0%,100%,.2)}.hover-white-10:focus,.hover-white-10:hover{color:hsla(0,0%,100%,.1)}.hover-inherit:focus,.hover-inherit:hover{color:inherit}.hover-bg-black:focus,.hover-bg-black:hover{background-color:#000}.hover-bg-near-black:focus,.hover-bg-near-black:hover{background-color:#111}.hover-bg-dark-gray:focus,.hover-bg-dark-gray:hover{background-color:#333}.hover-bg-mid-gray:focus,.hover-bg-mid-gray:hover{background-color:#555}.hover-bg-gray:focus,.hover-bg-gray:hover{background-color:#777}.hover-bg-silver:focus,.hover-bg-silver:hover{background-color:#999}.hover-bg-light-silver:focus,.hover-bg-light-silver:hover{background-color:#aaa}.hover-bg-moon-gray:focus,.hover-bg-moon-gray:hover{background-color:#ccc}.hover-bg-light-gray:focus,.hover-bg-light-gray:hover{background-color:#eee}.hover-bg-near-white:focus,.hover-bg-near-white:hover{background-color:#f4f4f4}.hover-bg-white:focus,.hover-bg-white:hover{background-color:#fff}.hover-bg-transparent:focus,.hover-bg-transparent:hover{background-color:transparent}.hover-bg-black-90:focus,.hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:focus,.hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:focus,.hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:focus,.hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:focus,.hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:focus,.hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:focus,.hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:focus,.hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:focus,.hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:focus,.hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.hover-bg-white-80:focus,.hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.hover-bg-white-70:focus,.hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.hover-bg-white-60:focus,.hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.hover-bg-white-50:focus,.hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.hover-bg-white-40:focus,.hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.hover-bg-white-30:focus,.hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.hover-bg-white-20:focus,.hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.hover-bg-white-10:focus,.hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.hover-dark-red:focus,.hover-dark-red:hover{color:#e7040f}.hover-red:focus,.hover-red:hover{color:#ff4136}.hover-light-red:focus,.hover-light-red:hover{color:#ff725c}.hover-orange:focus,.hover-orange:hover{color:#ff6300}.hover-gold:focus,.hover-gold:hover{color:#ffb700}.hover-yellow:focus,.hover-yellow:hover{color:gold}.hover-light-yellow:focus,.hover-light-yellow:hover{color:#fbf1a9}.hover-purple:focus,.hover-purple:hover{color:#5e2ca5}.hover-light-purple:focus,.hover-light-purple:hover{color:#a463f2}.hover-dark-pink:focus,.hover-dark-pink:hover{color:#d5008f}.hover-hot-pink:focus,.hover-hot-pink:hover{color:#ff41b4}.hover-pink:focus,.hover-pink:hover{color:#ff80cc}.hover-light-pink:focus,.hover-light-pink:hover{color:#ffa3d7}.hover-dark-green:focus,.hover-dark-green:hover{color:#137752}.hover-green:focus,.hover-green:hover{color:#19a974}.hover-light-green:focus,.hover-light-green:hover{color:#9eebcf}.hover-navy:focus,.hover-navy:hover{color:#001b44}.hover-dark-blue:focus,.hover-dark-blue:hover{color:#00449e}.hover-blue:focus,.hover-blue:hover{color:#357edd}.hover-light-blue:focus,.hover-light-blue:hover{color:#96ccff}.hover-lightest-blue:focus,.hover-lightest-blue:hover{color:#cdecff}.hover-washed-blue:focus,.hover-washed-blue:hover{color:#f6fffe}.hover-washed-green:focus,.hover-washed-green:hover{color:#e8fdf5}.hover-washed-yellow:focus,.hover-washed-yellow:hover{color:#fffceb}.hover-washed-red:focus,.hover-washed-red:hover{color:#ffdfdf}.hover-bg-dark-red:focus,.hover-bg-dark-red:hover{background-color:#e7040f}.hover-bg-red:focus,.hover-bg-red:hover{background-color:#ff4136}.hover-bg-light-red:focus,.hover-bg-light-red:hover{background-color:#ff725c}.hover-bg-orange:focus,.hover-bg-orange:hover{background-color:#ff6300}.hover-bg-gold:focus,.hover-bg-gold:hover{background-color:#ffb700}.hover-bg-yellow:focus,.hover-bg-yellow:hover{background-color:gold}.hover-bg-light-yellow:focus,.hover-bg-light-yellow:hover{background-color:#fbf1a9}.hover-bg-purple:focus,.hover-bg-purple:hover{background-color:#5e2ca5}.hover-bg-light-purple:focus,.hover-bg-light-purple:hover{background-color:#a463f2}.hover-bg-dark-pink:focus,.hover-bg-dark-pink:hover{background-color:#d5008f}.hover-bg-hot-pink:focus,.hover-bg-hot-pink:hover{background-color:#ff41b4}.hover-bg-pink:focus,.hover-bg-pink:hover{background-color:#ff80cc}.hover-bg-light-pink:focus,.hover-bg-light-pink:hover{background-color:#ffa3d7}.hover-bg-dark-green:focus,.hover-bg-dark-green:hover{background-color:#137752}.hover-bg-green:focus,.hover-bg-green:hover{background-color:#19a974}.hover-bg-light-green:focus,.hover-bg-light-green:hover{background-color:#9eebcf}.hover-bg-navy:focus,.hover-bg-navy:hover{background-color:#001b44}.hover-bg-dark-blue:focus,.hover-bg-dark-blue:hover{background-color:#00449e}.hover-bg-blue:focus,.hover-bg-blue:hover{background-color:#357edd}.hover-bg-light-blue:focus,.hover-bg-light-blue:hover{background-color:#96ccff}.hover-bg-lightest-blue:focus,.hover-bg-lightest-blue:hover{background-color:#cdecff}.hover-bg-washed-blue:focus,.hover-bg-washed-blue:hover{background-color:#f6fffe}.hover-bg-washed-green:focus,.hover-bg-washed-green:hover{background-color:#e8fdf5}.hover-bg-washed-yellow:focus,.hover-bg-washed-yellow:hover{background-color:#fffceb}.hover-bg-washed-red:focus,.hover-bg-washed-red:hover{background-color:#ffdfdf}.hover-bg-inherit:focus,.hover-bg-inherit:hover{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.overflow-container{overflow-y:scroll}.center{margin-left:auto}.center,.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}.dim{opacity:1}.dim,.dim:focus,.dim:hover{transition:opacity .15s ease-in}.dim:focus,.dim:hover{opacity:.5}.dim:active{opacity:.8;transition:opacity .15s ease-out}.glow,.glow:focus,.glow:hover{transition:opacity .15s ease-in}.glow:focus,.glow:hover{opacity:1}.hide-child .child{opacity:0;transition:opacity .15s ease-in}.hide-child:active .child,.hide-child:focus .child,.hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.underline-hover:focus,.underline-hover:hover{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:focus,.grow:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.9);transform:scale(.9)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:focus,.grow-large:hover{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover,.shadow-hover{cursor:pointer}.shadow-hover{position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:after{content:"";box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;transition:opacity .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:focus:after,.shadow-hover:hover:after{opacity:1}.bg-animate,.bg-animate:focus,.bg-animate:hover{transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto}.z-unset{z-index:unset}.nested-copy-line-height ol,.nested-copy-line-height p,.nested-copy-line-height ul{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ol,.nested-list-reset ul{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#357edd;transition:color .15s ease-in}.nested-links a:focus,.nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.debug *{outline:1px solid gold}.debug-white *{outline:1px solid #fff}.debug-black *{outline:1px solid #000}.debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4AWPAC97/9x0eCsAEPgwAVLshdpENIxcAAAAASUVORK5CYII=) repeat 0 0}.debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMklEQVR4AWOgCLz/b0epAa6UGuBOqQHOQHLUgFEDnAbcBZ4UGwDOkiCnkIhdgNgNxAYAiYlD+8sEuo8AAAAASUVORK5CYII=) repeat 0 0}.debug-grid-8-solid{background:#fff url(data:image/gif;base64,R0lGODdhCAAIAPEAAADw/wDx/////wAAACwAAAAACAAIAAACDZQvgaeb/lxbAIKA8y0AOw==) repeat 0 0}.debug-grid-16-solid{background:#fff url(data:image/gif;base64,R0lGODdhEAAQAPEAAADw/wDx/xXy/////ywAAAAAEAAQAAACIZyPKckYDQFsb6ZqD85jZ2+BkwiRFKehhqQCQgDHcgwEBQA7) repeat 0 0}@media screen and (min-width:30em){.aspect-ratio-ns{height:0;position:relative}.aspect-ratio--16x9-ns{padding-bottom:56.25%}.aspect-ratio--9x16-ns{padding-bottom:177.77%}.aspect-ratio--4x3-ns{padding-bottom:75%}.aspect-ratio--3x4-ns{padding-bottom:133.33%}.aspect-ratio--6x4-ns{padding-bottom:66.6%}.aspect-ratio--4x6-ns{padding-bottom:150%}.aspect-ratio--8x5-ns{padding-bottom:62.5%}.aspect-ratio--5x8-ns{padding-bottom:160%}.aspect-ratio--7x5-ns{padding-bottom:71.42%}.aspect-ratio--5x7-ns{padding-bottom:140%}.aspect-ratio--1x1-ns{padding-bottom:100%}.aspect-ratio--object-ns{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}.bg-center-ns{background-position:50%}.bg-center-ns,.bg-top-ns{background-repeat:no-repeat}.bg-top-ns{background-position:top}.bg-right-ns{background-position:100%}.bg-bottom-ns,.bg-right-ns{background-repeat:no-repeat}.bg-bottom-ns{background-position:bottom}.bg-left-ns{background-repeat:no-repeat;background-position:0}.outline-ns{outline:1px solid}.outline-transparent-ns{outline:1px solid transparent}.outline-0-ns{outline:0}.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-right-radius:0}.br--right-ns,.br--top-ns{border-bottom-left-radius:0}.br--right-ns{border-top-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}.shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}.flex-ns{display:flex}.inline-flex-ns{display:inline-flex}.flex-auto-ns{flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{flex:none}.flex-column-ns{flex-direction:column}.flex-row-ns{flex-direction:row}.flex-wrap-ns{flex-wrap:wrap}.flex-nowrap-ns{flex-wrap:nowrap}.flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.flex-column-reverse-ns{flex-direction:column-reverse}.flex-row-reverse-ns{flex-direction:row-reverse}.items-start-ns{align-items:flex-start}.items-end-ns{align-items:flex-end}.items-center-ns{align-items:center}.items-baseline-ns{align-items:baseline}.items-stretch-ns{align-items:stretch}.self-start-ns{align-self:flex-start}.self-end-ns{align-self:flex-end}.self-center-ns{align-self:center}.self-baseline-ns{align-self:baseline}.self-stretch-ns{align-self:stretch}.justify-start-ns{justify-content:flex-start}.justify-end-ns{justify-content:flex-end}.justify-center-ns{justify-content:center}.justify-between-ns{justify-content:space-between}.justify-around-ns{justify-content:space-around}.content-start-ns{align-content:flex-start}.content-end-ns{align-content:flex-end}.content-center-ns{align-content:center}.content-between-ns{align-content:space-between}.content-around-ns{align-content:space-around}.content-stretch-ns{align-content:stretch}.order-0-ns{order:0}.order-1-ns{order:1}.order-2-ns{order:2}.order-3-ns{order:3}.order-4-ns{order:4}.order-5-ns{order:5}.order-6-ns{order:6}.order-7-ns{order:7}.order-8-ns{order:8}.order-last-ns{order:99999}.flex-grow-0-ns{flex-grow:0}.flex-grow-1-ns{flex-grow:1}.flex-shrink-0-ns{flex-shrink:0}.flex-shrink-1-ns{flex-shrink:1}.fl-ns{float:left}.fl-ns,.fr-ns{_display:inline}.fr-ns{float:right}.fn-ns{float:none}.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}.rotate-45-ns{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-ns{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-ns{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-ns{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-ns{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-ns{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-ns{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-ns{margin-left:auto}.center-ns,.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.aspect-ratio-m{height:0;position:relative}.aspect-ratio--16x9-m{padding-bottom:56.25%}.aspect-ratio--9x16-m{padding-bottom:177.77%}.aspect-ratio--4x3-m{padding-bottom:75%}.aspect-ratio--3x4-m{padding-bottom:133.33%}.aspect-ratio--6x4-m{padding-bottom:66.6%}.aspect-ratio--4x6-m{padding-bottom:150%}.aspect-ratio--8x5-m{padding-bottom:62.5%}.aspect-ratio--5x8-m{padding-bottom:160%}.aspect-ratio--7x5-m{padding-bottom:71.42%}.aspect-ratio--5x7-m{padding-bottom:140%}.aspect-ratio--1x1-m{padding-bottom:100%}.aspect-ratio--object-m{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}.bg-center-m{background-position:50%}.bg-center-m,.bg-top-m{background-repeat:no-repeat}.bg-top-m{background-position:top}.bg-right-m{background-position:100%}.bg-bottom-m,.bg-right-m{background-repeat:no-repeat}.bg-bottom-m{background-position:bottom}.bg-left-m{background-repeat:no-repeat;background-position:0}.outline-m{outline:1px solid}.outline-transparent-m{outline:1px solid transparent}.outline-0-m{outline:0}.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-right-radius:0}.br--right-m,.br--top-m{border-bottom-left-radius:0}.br--right-m{border-top-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}.shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}.flex-m{display:flex}.inline-flex-m{display:inline-flex}.flex-auto-m{flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{flex:none}.flex-column-m{flex-direction:column}.flex-row-m{flex-direction:row}.flex-wrap-m{flex-wrap:wrap}.flex-nowrap-m{flex-wrap:nowrap}.flex-wrap-reverse-m{flex-wrap:wrap-reverse}.flex-column-reverse-m{flex-direction:column-reverse}.flex-row-reverse-m{flex-direction:row-reverse}.items-start-m{align-items:flex-start}.items-end-m{align-items:flex-end}.items-center-m{align-items:center}.items-baseline-m{align-items:baseline}.items-stretch-m{align-items:stretch}.self-start-m{align-self:flex-start}.self-end-m{align-self:flex-end}.self-center-m{align-self:center}.self-baseline-m{align-self:baseline}.self-stretch-m{align-self:stretch}.justify-start-m{justify-content:flex-start}.justify-end-m{justify-content:flex-end}.justify-center-m{justify-content:center}.justify-between-m{justify-content:space-between}.justify-around-m{justify-content:space-around}.content-start-m{align-content:flex-start}.content-end-m{align-content:flex-end}.content-center-m{align-content:center}.content-between-m{align-content:space-between}.content-around-m{align-content:space-around}.content-stretch-m{align-content:stretch}.order-0-m{order:0}.order-1-m{order:1}.order-2-m{order:2}.order-3-m{order:3}.order-4-m{order:4}.order-5-m{order:5}.order-6-m{order:6}.order-7-m{order:7}.order-8-m{order:8}.order-last-m{order:99999}.flex-grow-0-m{flex-grow:0}.flex-grow-1-m{flex-grow:1}.flex-shrink-0-m{flex-shrink:0}.flex-shrink-1-m{flex-shrink:1}.fl-m{float:left}.fl-m,.fr-m{_display:inline}.fr-m{float:right}.fn-m{float:none}.i-m{font-style:italic}.fs-normal-m{font-style:normal}.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}.rotate-45-m{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-m{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-m{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-m{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-m{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-m{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-m{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-m{margin-left:auto}.center-m,.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.aspect-ratio-l{height:0;position:relative}.aspect-ratio--16x9-l{padding-bottom:56.25%}.aspect-ratio--9x16-l{padding-bottom:177.77%}.aspect-ratio--4x3-l{padding-bottom:75%}.aspect-ratio--3x4-l{padding-bottom:133.33%}.aspect-ratio--6x4-l{padding-bottom:66.6%}.aspect-ratio--4x6-l{padding-bottom:150%}.aspect-ratio--8x5-l{padding-bottom:62.5%}.aspect-ratio--5x8-l{padding-bottom:160%}.aspect-ratio--7x5-l{padding-bottom:71.42%}.aspect-ratio--5x7-l{padding-bottom:140%}.aspect-ratio--1x1-l{padding-bottom:100%}.aspect-ratio--object-l{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}.bg-center-l{background-position:50%}.bg-center-l,.bg-top-l{background-repeat:no-repeat}.bg-top-l{background-position:top}.bg-right-l{background-position:100%}.bg-bottom-l,.bg-right-l{background-repeat:no-repeat}.bg-bottom-l{background-position:bottom}.bg-left-l{background-repeat:no-repeat;background-position:0}.outline-l{outline:1px solid}.outline-transparent-l{outline:1px solid transparent}.outline-0-l{outline:0}.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-right-radius:0}.br--right-l,.br--top-l{border-bottom-left-radius:0}.br--right-l{border-top-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}.shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}.flex-l{display:flex}.inline-flex-l{display:inline-flex}.flex-auto-l{flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{flex:none}.flex-column-l{flex-direction:column}.flex-row-l{flex-direction:row}.flex-wrap-l{flex-wrap:wrap}.flex-nowrap-l{flex-wrap:nowrap}.flex-wrap-reverse-l{flex-wrap:wrap-reverse}.flex-column-reverse-l{flex-direction:column-reverse}.flex-row-reverse-l{flex-direction:row-reverse}.items-start-l{align-items:flex-start}.items-end-l{align-items:flex-end}.items-center-l{align-items:center}.items-baseline-l{align-items:baseline}.items-stretch-l{align-items:stretch}.self-start-l{align-self:flex-start}.self-end-l{align-self:flex-end}.self-center-l{align-self:center}.self-baseline-l{align-self:baseline}.self-stretch-l{align-self:stretch}.justify-start-l{justify-content:flex-start}.justify-end-l{justify-content:flex-end}.justify-center-l{justify-content:center}.justify-between-l{justify-content:space-between}.justify-around-l{justify-content:space-around}.content-start-l{align-content:flex-start}.content-end-l{align-content:flex-end}.content-center-l{align-content:center}.content-between-l{align-content:space-between}.content-around-l{align-content:space-around}.content-stretch-l{align-content:stretch}.order-0-l{order:0}.order-1-l{order:1}.order-2-l{order:2}.order-3-l{order:3}.order-4-l{order:4}.order-5-l{order:5}.order-6-l{order:6}.order-7-l{order:7}.order-8-l{order:8}.order-last-l{order:99999}.flex-grow-0-l{flex-grow:0}.flex-grow-1-l{flex-grow:1}.flex-shrink-0-l{flex-shrink:0}.flex-shrink-1-l{flex-shrink:1}.fl-l{float:left}.fl-l,.fr-l{_display:inline}.fr-l{float:right}.fn-l{float:none}.i-l{font-style:italic}.fs-normal-l{font-style:normal}.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}.rotate-45-l{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-l{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-l{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-l{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-l{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-l{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-l{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-l{margin-left:auto}.center-l,.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}}
3 |
4 |
--------------------------------------------------------------------------------