├── .air.toml ├── .devcontainer └── devcontainer.json ├── .github └── dependabot.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── internal ├── cache │ ├── cache.go │ ├── memory_cache.go │ └── swr_cache.go ├── handlers │ ├── advertise.go │ ├── confirm.go │ ├── generate.go │ ├── privacy.go │ ├── root.go │ ├── rss_betas.go │ ├── rss_everything.go │ ├── rss_tools.go │ └── selection-criteria.go ├── middleware │ ├── logger.go │ ├── middleware.go │ └── recover.go ├── notion │ ├── notion.go │ ├── query_betas.go │ ├── query_common.go │ └── query_tools.go └── rss │ └── rss.go ├── main.go ├── package-lock.json ├── package.json ├── tailwind.config.js └── web ├── static ├── css │ ├── input.css │ ├── new.css │ └── output.css ├── fonts │ ├── Rubik-Bold.woff2 │ ├── Rubik-Medium.woff2 │ └── Rubik-Regular.woff2 ├── img │ ├── console-logo-dark.svg │ ├── console-logo-light.svg │ ├── console-mark.png │ ├── favicon.ico │ ├── favicon.png │ ├── favicon.svg │ └── not-found.jpg └── robots.txt ├── templates.go └── templates ├── 404.html ├── advertise.html ├── confirm.html ├── footer.html ├── generate.html ├── generate_betas.html ├── generate_tools.html ├── head.html ├── header.html ├── index.html ├── privacy.html └── selection-criteria.html /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go generate && go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["node_modules", "assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "css"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | silent = false 40 | time = false 41 | 42 | [misc] 43 | clean_on_exit = true 44 | 45 | [proxy] 46 | app_port = 0 47 | enabled = false 48 | proxy_port = 0 49 | 50 | [screen] 51 | clear_on_rebuild = false 52 | keep_scroll = true 53 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go 3 | { 4 | "name": "Go", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm", 7 | "features": { 8 | "ghcr.io/devcontainers/features/node:1": {} 9 | }, 10 | "remoteEnv": { 11 | "NOTION_SECRET": "${localEnv:NOTION_SECRET}" 12 | }, 13 | // Features to add to the dev container. More info: https://containers.dev/features. 14 | // "features": {}, 15 | // Use 'forwardPorts' to make a list of ports inside "?|"nd' to run commands after the container is created. 16 | "postCreateCommand": "npm ci && go install github.com/air-verse/air@v1.61.5" 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 20 | // "remoteUser": "root" 21 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | 14 | - package-ecosystem: gomod 15 | directory: / 16 | schedule: 17 | interval: weekly 18 | 19 | - package-ecosystem: npm 20 | directory: / 21 | schedule: 22 | interval: weekly 23 | 24 | - package-ecosystem: docker 25 | directory: / 26 | schedule: 27 | interval: weekly 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | lerna-debug.log* 34 | .pnpm-debug.log* 35 | 36 | # Diagnostic reports (https://nodejs.org/api/report.html) 37 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 38 | 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Directory for instrumented libs generated by jscoverage/JSCover 46 | lib-cov 47 | 48 | # Coverage directory used by tools like istanbul 49 | coverage 50 | *.lcov 51 | 52 | # nyc test coverage 53 | .nyc_output 54 | 55 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 56 | .grunt 57 | 58 | # Bower dependency directory (https://bower.io/) 59 | bower_components 60 | 61 | # node-waf configuration 62 | .lock-wscript 63 | 64 | # Compiled binary addons (https://nodejs.org/api/addons.html) 65 | build/Release 66 | 67 | # Dependency directories 68 | node_modules/ 69 | jspm_packages/ 70 | 71 | # Snowpack dependency directory (https://snowpack.dev/) 72 | web_modules/ 73 | 74 | # TypeScript cache 75 | *.tsbuildinfo 76 | 77 | # Optional npm cache directory 78 | .npm 79 | 80 | # Optional eslint cache 81 | .eslintcache 82 | 83 | # Optional stylelint cache 84 | .stylelintcache 85 | 86 | # Microbundle cache 87 | .rpt2_cache/ 88 | .rts2_cache_cjs/ 89 | .rts2_cache_es/ 90 | .rts2_cache_umd/ 91 | 92 | # Optional REPL history 93 | .node_repl_history 94 | 95 | # Output of 'npm pack' 96 | *.tgz 97 | 98 | # Yarn Integrity file 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | .env 103 | .env.development.local 104 | .env.test.local 105 | .env.production.local 106 | .env.local 107 | 108 | # parcel-bundler cache (https://parceljs.org/) 109 | .cache 110 | .parcel-cache 111 | 112 | # Next.js build output 113 | .next 114 | out 115 | 116 | # Nuxt.js build / generate output 117 | .nuxt 118 | dist 119 | 120 | # Gatsby files 121 | .cache/ 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | # https://nextjs.org/blog/next-9-1#public-directory-support 124 | # public 125 | 126 | # vuepress build output 127 | .vuepress/dist 128 | 129 | # vuepress v2.x temp and cache directory 130 | .temp 131 | .cache 132 | 133 | # Docusaurus cache and generated files 134 | .docusaurus 135 | 136 | # Serverless directories 137 | .serverless/ 138 | 139 | # FuseBox cache 140 | .fusebox/ 141 | 142 | # DynamoDB Local files 143 | .dynamodb/ 144 | 145 | # TernJS port file 146 | .tern-port 147 | 148 | # Stores VSCode versions used for testing VSCode extensions 149 | .vscode-test 150 | 151 | # yarn v2 152 | .yarn/cache 153 | .yarn/unplugged 154 | .yarn/build-state.yml 155 | .yarn/install-state.gz 156 | .pnp.* 157 | 158 | tmp 159 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "makefile.configureOnOpen": false 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3 AS builder 2 | 3 | WORKDIR /app 4 | COPY go.mod ./ 5 | 6 | ENV CGO_ENABLED=0 7 | RUN go mod download 8 | 9 | COPY . . 10 | RUN go build -o server main.go 11 | 12 | FROM gcr.io/distroless/static-debian12:nonroot 13 | COPY --from=builder /app/server / 14 | 15 | CMD ["/server"] 16 | 17 | USER nonroot -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (C) Console. All rights reserved. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: watch build 2 | 3 | dev: 4 | @if command -v $(HOME)/go/bin/air > /dev/null; then \ 5 | AIR_CMD="$(HOME)/go/bin/air"; \ 6 | elif command -v air > /dev/null; then \ 7 | AIR_CMD="air"; \ 8 | else \ 9 | read -p "air is not installed. Install it? [Y/n] " choice; \ 10 | if [ "$$choice" != "n" ] && [ "$$choice" != "N" ]; then \ 11 | echo "Installing..."; \ 12 | go install github.com/air-verse/air@latest; \ 13 | AIR_CMD="$(HOME)/go/bin/air"; \ 14 | else \ 15 | echo "Exiting..."; \ 16 | exit 1; \ 17 | fi; \ 18 | fi; \ 19 | if [ -z "$$NOTION_SECRET" ]; then \ 20 | echo "Setting NOTION_SECRET..."; \ 21 | export NOTION_SECRET=$$(op --account consoledotdev.1password.com \ 22 | read "op://Home/Notion API Key/credential"); \ 23 | fi; \ 24 | export DEBUG=true; \ 25 | echo "Starting Air..."; \ 26 | $$AIR_CMD 27 | 28 | build: 29 | @echo "Installing Tailwind..." 30 | npm ci 31 | @echo "Generate Tailwind CSS..." 32 | go generate 33 | @echo "Building Go server..." 34 | go build -o tmp/server main.go 35 | @echo "Build complete." -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # console.dev 2 | 3 | The [console.dev](https://console.dev) web site implemented using HTML + 4 | Tailwind and hosted using a static Go server. 5 | 6 | ## Development 7 | 8 | ### Devcontainer 9 | 10 | Launch with this command to inject the secret: 11 | 12 | ```sh 13 | export NOTION_SECRET="op://Home/Notion API Key/credential" && op run -- code . 14 | ``` 15 | 16 | This will start VS Code with the secret set. You can reopen in the devcontainer 17 | and it will be available in the environment. 18 | 19 | ### Dev server 20 | 21 | Start the development server which will watch for changes and rebuild the site 22 | automatically. 23 | 24 | ```sh 25 | make dev 26 | ``` 27 | 28 | ## Build 29 | 30 | To build the site for production, run: 31 | 32 | ```sh 33 | make build 34 | ``` 35 | 36 | ### Build notes 37 | 38 | - The site is built using Tailwind CSS. The CSS is generated automatically with 39 | `go generate` which runs the `tailwindcss` command. 40 | - CSS is built from `static/css/input.css` combined with the Tailwind attributes 41 | in HTML to generate the final `static/css/output.css` file. 42 | - Build the Docker image with `docker build -t console.dev --load .` and run it 43 | with `docker run -t console.dev`. 44 | 45 | ## Configuration 46 | 47 | The site is configured using environment variables: 48 | 49 | - `PORT`: The port to listen on. Default is `8080`. 50 | - `DEBUG`: Set to `true` to enable debug logging. Default is `false`. 51 | - `JSON_LOGGER`: Set to `true` to log in JSON format. Default is `false`. 52 | - `NOTION_SECRET`: API secret for [Notion 53 | integration](https://www.notion.so/my-integrations). 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/consoledotdev/home 2 | 3 | go 1.24.0 4 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/consoledotdev/home/internal/notion" 7 | ) 8 | 9 | // Cache is an interface for caching tools and betas. This could use generics, 10 | // but it was simpler to duplicate the methods for now. 11 | type Cache interface { 12 | GetTools() ([]notion.Tool, error) // Returns cached tools 13 | GetBetas() ([]notion.Beta, error) // Returns cached betas 14 | GetNewsletterDate() (time.Time, error) // Returns cached newsletter date 15 | SaveTools(tools []notion.Tool) error // Saves or updates tools in cache 16 | SaveBetas(betas []notion.Beta) error // Saves or updates betas in cache 17 | SaveNewsletterDate(date time.Time) error // Saves or updates newsletter date in cache 18 | Expires() (time.Time, error) // Returns the time of the next expiration 19 | SetExpires(ts time.Time) error // Sets the time of the next expiration 20 | } 21 | -------------------------------------------------------------------------------- /internal/cache/memory_cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/consoledotdev/home/internal/notion" 8 | ) 9 | 10 | type MemoryCache struct { 11 | mu sync.RWMutex 12 | tools []notion.Tool 13 | betas []notion.Beta 14 | newsletterDate time.Time 15 | expires int64 16 | } 17 | 18 | func NewMemoryCache() *MemoryCache { 19 | return &MemoryCache{} 20 | } 21 | 22 | func (m *MemoryCache) GetTools() ([]notion.Tool, error) { 23 | m.mu.RLock() 24 | defer m.mu.RUnlock() 25 | return m.tools, nil 26 | } 27 | 28 | func (m *MemoryCache) GetBetas() ([]notion.Beta, error) { 29 | m.mu.RLock() 30 | defer m.mu.RUnlock() 31 | return m.betas, nil 32 | } 33 | 34 | func (m *MemoryCache) GetNewsletterDate() (time.Time, error) { 35 | m.mu.RLock() 36 | defer m.mu.RUnlock() 37 | return m.newsletterDate, nil 38 | } 39 | 40 | func (m *MemoryCache) SaveTools(tools []notion.Tool) error { 41 | m.mu.Lock() 42 | defer m.mu.Unlock() 43 | m.tools = tools 44 | return nil 45 | } 46 | 47 | func (m *MemoryCache) SaveBetas(betas []notion.Beta) error { 48 | m.mu.Lock() 49 | defer m.mu.Unlock() 50 | m.betas = betas 51 | return nil 52 | } 53 | 54 | func (m *MemoryCache) SaveNewsletterDate(date time.Time) error { 55 | m.mu.Lock() 56 | defer m.mu.Unlock() 57 | m.newsletterDate = date 58 | return nil 59 | } 60 | 61 | func (m *MemoryCache) Expires() (time.Time, error) { 62 | m.mu.RLock() 63 | defer m.mu.RUnlock() 64 | return time.Unix(m.expires, 0), nil 65 | } 66 | 67 | func (m *MemoryCache) SetExpires(ts time.Time) error { 68 | m.mu.Lock() 69 | defer m.mu.Unlock() 70 | m.expires = ts.Unix() 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /internal/cache/swr_cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "time" 5 | 6 | "log/slog" 7 | 8 | "github.com/consoledotdev/home/internal/notion" 9 | ) 10 | 11 | // SwrCache is a cache that implements stale-while-revalidate logic. It will 12 | // return stale data immediately and revalidate in the background using the 13 | // provided fetch functions. 14 | type SwrCache struct { 15 | Cache Cache 16 | FetchToolsFunc func() ([]notion.Tool, *time.Time, error) 17 | FetchBetasFunc func() ([]notion.Beta, *time.Time, error) 18 | } 19 | 20 | // midnightTonight returns a time.Time pointing to midnight local time 21 | func midnightTonight() time.Time { 22 | now := time.Now() 23 | return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) 24 | } 25 | 26 | // GetTools returns the latest tools and betas with SWR logic. 27 | func (s *SwrCache) GetToolsAndBetas() ([]notion.Tool, []notion.Beta, *time.Time, error) { 28 | slog.Debug("Fetching from cache") 29 | 30 | tools, err := s.Cache.GetTools() 31 | if err != nil { 32 | slog.Error("Failed to load tools from cache", "error", err) 33 | // Return empty if cache load fails 34 | return nil, nil, nil, err 35 | } 36 | 37 | betas, err := s.Cache.GetBetas() 38 | if err != nil { 39 | slog.Error("Failed to load betas from cache", "error", err) 40 | // Return empty if cache load fails 41 | return nil, nil, nil, err 42 | } 43 | 44 | newsletterDate, err := s.Cache.GetNewsletterDate() 45 | if err != nil { 46 | slog.Error("Failed to load newsletter date from cache", "error", err) 47 | // Return empty if cache load fails 48 | return nil, nil, nil, err 49 | } 50 | 51 | // Check last update time 52 | expires, _ := s.Cache.Expires() 53 | 54 | // If it's stale (past midnight) => revalidate 55 | if time.Now().After(expires) { 56 | slog.Info("Cache is stale, revalidating") 57 | // Kick off a background refresh 58 | go func() { 59 | // Both return the newsletter date, but only use it from the betas 60 | newTools, _, ferr := s.FetchToolsFunc() 61 | if ferr != nil { 62 | slog.Error("Failed to fetch new tools", "error", ferr) 63 | return 64 | } 65 | if err := s.Cache.SaveTools(newTools); err != nil { 66 | slog.Error("Failed to save tools to cache", "error", err) 67 | return 68 | } 69 | 70 | newBetas, newsletterDate, ferr := s.FetchBetasFunc() 71 | if ferr != nil { 72 | slog.Error("Failed to fetch new betas", "error", ferr) 73 | return 74 | } 75 | 76 | if err := s.Cache.SaveBetas(newBetas); err != nil { 77 | slog.Error("Failed to save betas to cache", "error", err) 78 | return 79 | } 80 | 81 | if newsletterDate != nil { 82 | if err := s.Cache.SaveNewsletterDate(*newsletterDate); err != nil { 83 | slog.Error("Failed to save newsletter date to cache", "error", err) 84 | return 85 | } 86 | } 87 | 88 | // Set next TTL to midnight 89 | nextMidnight := midnightTonight() 90 | _ = s.Cache.SetExpires(nextMidnight) 91 | 92 | slog.Info("Cache successfully revalidated", 93 | slog.Time("expires", nextMidnight), 94 | slog.Duration("expires_duration", time.Until(nextMidnight))) 95 | }() 96 | } 97 | 98 | // Return old (possibly stale) data immediately 99 | slog.Debug("Returning from cache", slog.Time("expires", expires)) 100 | return tools, betas, &newsletterDate, nil 101 | } 102 | -------------------------------------------------------------------------------- /internal/handlers/advertise.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/consoledotdev/home/web" 8 | ) 9 | 10 | type AdvertiseData struct { 11 | } 12 | 13 | func AdvertiseHandler() http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | web.Render(w, "advertise.html", AdvertiseData{}) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/handlers/confirm.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/consoledotdev/home/web" 8 | ) 9 | 10 | type ConfirmData struct { 11 | } 12 | 13 | func ConfirmHandler() http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | web.Render(w, "confirm.html", PrivacyData{}) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/handlers/generate.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "log/slog" 10 | 11 | "github.com/consoledotdev/home/internal/notion" 12 | "github.com/consoledotdev/home/web" 13 | ) 14 | 15 | type GenerateData struct { 16 | Tools []notion.Tool 17 | Betas []notion.Beta 18 | Preview string 19 | NewsletterDate string 20 | } 21 | 22 | func GenerateHandler(notionClient *notion.NotionClient) http.Handler { 23 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | tools, newsletterDate, err := notionClient.GetNextTools() 25 | if err != nil { 26 | slog.Error("Error fetching tools", "error", err) 27 | http.Error(w, err.Error(), http.StatusInternalServerError) 28 | return 29 | } 30 | 31 | betas, _, err := notionClient.GetNextBetas() 32 | if err != nil { 33 | slog.Error("Error fetching betas", "error", err) 34 | http.Error(w, err.Error(), http.StatusInternalServerError) 35 | return 36 | } 37 | 38 | var names []string 39 | 40 | for i := range tools { 41 | // Append name for preview text 42 | names = append(names, tools[i].Name) 43 | } 44 | 45 | for i := range betas { 46 | // Append name for preview text 47 | names = append(names, betas[i].Name) 48 | } 49 | 50 | data := GenerateData{ 51 | Tools: tools, 52 | Betas: betas, 53 | Preview: fmt.Sprintf("%s - the best tools for developers", strings.Join(names, ", ")), 54 | NewsletterDate: newsletterDate.Format("2006-01-02"), 55 | } 56 | web.Render(w, "generate.html", data) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /internal/handlers/privacy.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/consoledotdev/home/web" 8 | ) 9 | 10 | type PrivacyData struct { 11 | } 12 | 13 | func PrivacyHandler() http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | web.Render(w, "privacy.html", PrivacyData{}) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/handlers/root.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "net/http" 6 | 7 | "log/slog" 8 | 9 | "github.com/consoledotdev/home/internal/cache" 10 | "github.com/consoledotdev/home/internal/notion" 11 | "github.com/consoledotdev/home/web" 12 | ) 13 | 14 | type RootData struct { 15 | Tools []notion.Tool 16 | Betas []notion.Beta 17 | NewsletterDate string 18 | } 19 | 20 | func RootHandler(swrCache *cache.SwrCache) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | tools, betas, newsletterDate, err := swrCache.GetToolsAndBetas() 23 | if err != nil { 24 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 25 | slog.Error("Error fetching tools", "error", err) 26 | return 27 | } 28 | 29 | data := RootData{ 30 | Tools: tools, 31 | Betas: betas, 32 | NewsletterDate: newsletterDate.Format("2006-01-02"), 33 | } 34 | web.Render(w, "index.html", data) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /internal/handlers/rss_betas.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "encoding/xml" 6 | "net/http" 7 | "time" 8 | 9 | "log/slog" 10 | 11 | "github.com/consoledotdev/home/internal/cache" 12 | "github.com/consoledotdev/home/internal/rss" 13 | ) 14 | 15 | func RssBetasHandler(swrCache *cache.SwrCache) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | _, betas, newsletterDateTime, err := swrCache.GetToolsAndBetas() 18 | if err != nil { 19 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 20 | slog.Error("Error fetching tools", "error", err) 21 | return 22 | } 23 | 24 | if len(betas) == 0 || newsletterDateTime == nil || newsletterDateTime.IsZero() { 25 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) 26 | slog.Error("Empty tools") 27 | return 28 | } 29 | 30 | // Set the publish time to midnight on the day of the newsletter 31 | newsletterDate := time.Date( 32 | newsletterDateTime.Year(), 33 | newsletterDateTime.Month(), 34 | newsletterDateTime.Day(), 0, 0, 0, 0, time.UTC) 35 | 36 | var items []rss.RssItem 37 | 38 | for i := range betas { 39 | description := rss.Description{ 40 | Text: betas[i].Description, 41 | } 42 | 43 | item := rss.RssItem{ 44 | Title: betas[i].Name, 45 | Description: description, 46 | Link: betas[i].URL, 47 | PubDate: newsletterDate.Format(time.RFC1123Z), 48 | } 49 | 50 | items = append(items, item) 51 | } 52 | 53 | feed := rss.NewRSS( 54 | "Console.dev Devtools Newsletter - Betas Feed", 55 | "Latest beta releases", 56 | newsletterDate, 57 | items, 58 | ) 59 | 60 | x, err := xml.MarshalIndent(feed, "", " ") 61 | if err != nil { 62 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 63 | slog.Error("Error marshalling rss", "error", err) 64 | return 65 | } 66 | 67 | w.Header().Set("Content-Type", "application/rss+xml; charset=UTF-8") 68 | w.Write(x) 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /internal/handlers/rss_everything.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "log/slog" 11 | 12 | "github.com/consoledotdev/home/internal/cache" 13 | "github.com/consoledotdev/home/internal/rss" 14 | ) 15 | 16 | func RssEverythingHandler(swrCache *cache.SwrCache) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | tools, betas, newsletterDateTime, err := swrCache.GetToolsAndBetas() 19 | if err != nil { 20 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 21 | slog.Error("Error fetching tools", "error", err) 22 | return 23 | } 24 | 25 | if len(betas) == 0 || newsletterDateTime == nil || newsletterDateTime.IsZero() { 26 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) 27 | slog.Error("Empty tools") 28 | return 29 | } 30 | 31 | // Set the publish time to midnight on the day of the newsletter 32 | newsletterDate := time.Date( 33 | newsletterDateTime.Year(), 34 | newsletterDateTime.Month(), 35 | newsletterDateTime.Day(), 0, 0, 0, 0, time.UTC) 36 | 37 | var items []rss.RssItem 38 | 39 | for i := range tools { 40 | description := rss.Description{ 41 | Text: fmt.Sprintf( 42 | "
Description: %s
What we like: %s
What we dislike: %s
", 43 | tools[i].Description, tools[i].Like, tools[i].Dislike, 44 | ), 45 | } 46 | 47 | item := rss.RssItem{ 48 | Title: fmt.Sprintf("Tool: %s", tools[i].Name), 49 | Description: description, 50 | Link: tools[i].URL, 51 | PubDate: newsletterDate.Format(time.RFC1123Z), 52 | } 53 | 54 | items = append(items, item) 55 | } 56 | 57 | for i := range betas { 58 | description := rss.Description{ 59 | Text: betas[i].Description, 60 | } 61 | 62 | item := rss.RssItem{ 63 | Title: fmt.Sprintf("Beta: %s", betas[i].Name), 64 | Description: description, 65 | Link: betas[i].URL, 66 | PubDate: newsletterDate.Format(time.RFC1123Z), 67 | } 68 | 69 | items = append(items, item) 70 | } 71 | 72 | feed := rss.NewRSS( 73 | "Console.dev Devtools Newsletter - Everything Feed", 74 | "Reviews of the most interesting devtools and latest beta releases.", 75 | newsletterDate, 76 | items, 77 | ) 78 | 79 | x, err := xml.MarshalIndent(feed, "", " ") 80 | if err != nil { 81 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 82 | slog.Error("Error marshalling rss", "error", err) 83 | return 84 | } 85 | 86 | w.Header().Set("Content-Type", "application/rss+xml; charset=UTF-8") 87 | w.Write(x) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /internal/handlers/rss_tools.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "encoding/xml" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "log/slog" 11 | 12 | "github.com/consoledotdev/home/internal/cache" 13 | "github.com/consoledotdev/home/internal/rss" 14 | ) 15 | 16 | func RssToolsHandler(swrCache *cache.SwrCache) http.Handler { 17 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | tools, _, newsletterDateTime, err := swrCache.GetToolsAndBetas() 19 | if err != nil { 20 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 21 | slog.Error("Error fetching tools", "error", err) 22 | return 23 | } 24 | 25 | if len(tools) == 0 || newsletterDateTime == nil || newsletterDateTime.IsZero() { 26 | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) 27 | slog.Error("Empty tools") 28 | return 29 | } 30 | 31 | // Set the publish time to midnight on the day of the newsletter 32 | newsletterDate := time.Date( 33 | newsletterDateTime.Year(), 34 | newsletterDateTime.Month(), 35 | newsletterDateTime.Day(), 0, 0, 0, 0, time.UTC) 36 | 37 | var items []rss.RssItem 38 | 39 | for i := range tools { 40 | description := rss.Description{ 41 | Text: fmt.Sprintf( 42 | "Description: %s
What we like: %s
What we dislike: %s
", 43 | tools[i].Description, tools[i].Like, tools[i].Dislike, 44 | ), 45 | } 46 | 47 | item := rss.RssItem{ 48 | Title: tools[i].Name, 49 | Description: description, 50 | Link: tools[i].URL, 51 | PubDate: newsletterDate.Format(time.RFC1123Z), 52 | } 53 | 54 | items = append(items, item) 55 | } 56 | 57 | feed := rss.NewRSS( 58 | "Console.dev Devtools Newsletter - Tools Feed", 59 | "Reviews of the most interesting devtools", 60 | newsletterDate, 61 | items, 62 | ) 63 | 64 | x, err := xml.MarshalIndent(feed, "", " ") 65 | if err != nil { 66 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 67 | slog.Error("Error marshalling rss", "error", err) 68 | return 69 | } 70 | 71 | w.Header().Set("Content-Type", "application/rss+xml; charset=UTF-8") 72 | w.Write(x) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /internal/handlers/selection-criteria.go: -------------------------------------------------------------------------------- 1 | // root.go 2 | package handlers 3 | 4 | import ( 5 | "net/http" 6 | 7 | "github.com/consoledotdev/home/web" 8 | ) 9 | 10 | type SelectionCriteriaData struct { 11 | } 12 | 13 | func SelectionCriteriaHandler() http.Handler { 14 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 15 | web.Render(w, "selection-criteria.html", SelectionCriteriaData{}) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /internal/middleware/logger.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type wrappedWriter struct { 10 | http.ResponseWriter 11 | statusCode int 12 | } 13 | 14 | func (w *wrappedWriter) WriteHeader(statusCode int) { 15 | w.ResponseWriter.WriteHeader(statusCode) 16 | w.statusCode = statusCode 17 | } 18 | 19 | func LoggerMiddleware(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | start := time.Now() 22 | 23 | wrapped := &wrappedWriter{ 24 | ResponseWriter: w, 25 | statusCode: http.StatusOK, 26 | } 27 | 28 | next.ServeHTTP(wrapped, r) 29 | 30 | var duration time.Duration 31 | if startTime, ok := r.Context().Value(startTimeKey).(time.Time); ok { 32 | duration = time.Since(startTime) 33 | } else { 34 | duration = time.Since(start) 35 | } 36 | 37 | slog.Debug("request", 38 | slog.Int("status", wrapped.statusCode), 39 | slog.String("method", r.Method), 40 | slog.String("path", r.URL.Path), 41 | slog.Duration("duration", duration), 42 | ) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /internal/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type contextKey string 10 | 11 | const startTimeKey contextKey = "startTime" 12 | 13 | // Middleware is a function that wraps an http.Handler with custom logic. 14 | type Middleware func(http.Handler) http.Handler 15 | 16 | // Chain is a helper to build up a pipeline of middlewares, then apply them to a 17 | // final handler. 18 | type Chain struct { 19 | middlewares []Middleware 20 | } 21 | 22 | // Use appends a middleware to the chain. 23 | func (c *Chain) Use(m Middleware) { 24 | c.middlewares = append(c.middlewares, m) 25 | } 26 | 27 | // Then applies the entire chain of middlewares to the final handler in reverse 28 | // order. 29 | func (c *Chain) Then(h http.Handler) http.Handler { 30 | // Add timing middleware as the first in the chain 31 | timerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | ctx := context.WithValue(r.Context(), startTimeKey, time.Now()) 33 | h.ServeHTTP(w, r.WithContext(ctx)) 34 | }) 35 | 36 | var finalHandler http.Handler = timerHandler 37 | for i := len(c.middlewares) - 1; i >= 0; i-- { 38 | finalHandler = c.middlewares[i](finalHandler) 39 | } 40 | return finalHandler 41 | } 42 | 43 | func (c *Chain) ThenFunc(handler http.HandlerFunc) http.Handler { 44 | return c.Then(http.HandlerFunc(handler)) 45 | } 46 | -------------------------------------------------------------------------------- /internal/middleware/recover.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | ) 7 | 8 | func RecoverMiddleware(next http.Handler) http.Handler { 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 10 | defer func() { 11 | if err := recover(); err != nil { 12 | slog.Error("Recovered from panic", "error", err) 13 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) 14 | } 15 | }() 16 | next.ServeHTTP(w, r) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/notion/notion.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | const ( 14 | NotionVersion = "2022-06-28" 15 | NotionBaseURL = "https://api.notion.com/v1" 16 | NotionDateFormat = "2006-01-02" 17 | ) 18 | 19 | // NotionClient holds configuration and an HTTP client for querying Notion. 20 | type NotionClient struct { 21 | secret string 22 | httpClient *http.Client 23 | } 24 | 25 | // NotionQueryRequest represents the request body sent to Notion. 26 | type NotionQueryRequest struct { 27 | Filter Filter `json:"filter,omitempty"` 28 | Sorts []Sort `json:"sorts,omitempty"` 29 | } 30 | 31 | // Filter represents the filtering options for the Notion query. 32 | type Filter struct { 33 | And []Condition `json:"and,omitempty"` 34 | Or []Condition `json:"or,omitempty"` 35 | } 36 | 37 | // Condition represents a single condition for the Notion query. 38 | type Condition struct { 39 | Property string `json:"property"` 40 | RichText *RichTextCondition `json:"rich_text,omitempty"` 41 | Status *StatusCondition `json:"status,omitempty"` 42 | Date *DateCondition `json:"date,omitempty"` 43 | } 44 | 45 | type RichTextCondition struct { 46 | IsNotEmpty bool `json:"is_not_empty"` 47 | } 48 | 49 | type StatusCondition struct { 50 | Equals string `json:"equals"` 51 | } 52 | 53 | type DateCondition struct { 54 | Equals string `json:"equals,omitempty"` 55 | } 56 | 57 | // Sort represents the sorting options for the Notion query. 58 | type Sort struct { 59 | Property string `json:"property,omitempty"` 60 | Direction string `json:"direction,omitempty"` 61 | } 62 | 63 | // Notion property types 64 | 65 | type NotionTitleProperty struct { 66 | Title []struct { 67 | PlainText string `json:"plain_text"` 68 | } `json:"title"` 69 | } 70 | 71 | type NotionRichTextProperty struct { 72 | RichText []struct { 73 | PlainText string `json:"plain_text"` 74 | } `json:"rich_text"` 75 | } 76 | 77 | type NotionSelectProperty struct { 78 | Select struct { 79 | Name string `json:"name"` 80 | } `json:"select"` 81 | } 82 | 83 | type NotionDateProperty struct { 84 | Date struct { 85 | Start string `json:"start"` 86 | } `json:"date"` 87 | } 88 | 89 | type NotionURLProperty struct { 90 | URL string `json:"url"` 91 | } 92 | 93 | type NotionCheckboxProperty struct { 94 | Checkbox bool `json:"checkbox"` 95 | } 96 | 97 | // NewNotionClient constructs a NotionClient. 98 | func NewNotionClient(secret string) (*NotionClient, error) { 99 | if secret == "" { 100 | return nil, fmt.Errorf("empty Notion secret") 101 | } 102 | return &NotionClient{ 103 | secret: secret, 104 | httpClient: &http.Client{Timeout: 10 * time.Second}, 105 | }, nil 106 | } 107 | 108 | // doRequest is a helper that takes an HTTP method, a Notion path 109 | // (like "/databases/{id}/query"), and a request body (if any). 110 | func (nc *NotionClient) doRequest( 111 | method, 112 | path string, 113 | body interface{}, 114 | ) ([]byte, error) { 115 | var reqBody io.Reader 116 | if body != nil { 117 | jsonBytes, err := json.Marshal(body) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to marshal request: %w", err) 120 | } 121 | slog.Debug("Request body", slog.String("body", string(jsonBytes))) 122 | reqBody = bytes.NewBuffer(jsonBytes) 123 | } 124 | 125 | endpoint := fmt.Sprintf("%s%s", NotionBaseURL, path) 126 | req, err := http.NewRequest(method, endpoint, reqBody) 127 | if err != nil { 128 | return nil, fmt.Errorf("failed to create request: %w", err) 129 | } 130 | 131 | // Set headers 132 | req.Header.Set("Authorization", "Bearer "+nc.secret) 133 | req.Header.Set("Notion-Version", NotionVersion) 134 | req.Header.Set("Content-Type", "application/json") 135 | 136 | slog.Info("Sending request to Notion", slog.String("method", method), slog.String("path", path)) 137 | 138 | // Execute 139 | res, err := nc.httpClient.Do(req) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to execute request: %w", err) 142 | } 143 | defer res.Body.Close() 144 | 145 | // Check status code 146 | if res.StatusCode != http.StatusOK { 147 | resBody, _ := io.ReadAll(res.Body) 148 | return nil, fmt.Errorf( 149 | "API non-200 response (%d): %s", 150 | res.StatusCode, 151 | string(resBody), 152 | ) 153 | } 154 | 155 | slog.Debug("Received response from Notion", slog.Int("status", res.StatusCode)) 156 | 157 | // Return the raw response 158 | return io.ReadAll(res.Body) 159 | } 160 | -------------------------------------------------------------------------------- /internal/notion/query_betas.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | const ( 12 | BetasDatabaseID = "15f9c7f3263b453caab4e276a50d93dd" 13 | ) 14 | 15 | // Represents the top-level Notion JSON response 16 | type NotionBetasResponse struct { 17 | Results []NotionBetasResult `json:"results"` 18 | } 19 | 20 | // Notion result for a single beta 21 | type NotionBetasResult struct { 22 | ID string `json:"id"` 23 | CreatedTime time.Time `json:"created_time"` 24 | LastEditedTime time.Time `json:"last_edited_time"` 25 | Properties NotionBetaProperties `json:"properties"` 26 | } 27 | 28 | // Notion properties for a single beta 29 | type NotionBetaProperties struct { 30 | Name NotionTitleProperty `json:"Name"` 31 | Announced NotionDateProperty `json:"Announced"` 32 | Description NotionRichTextProperty `json:"Description"` 33 | Category NotionSelectProperty `json:"Category"` 34 | Access NotionSelectProperty `json:"Access"` 35 | Type NotionSelectProperty `json:"Type"` 36 | URL NotionURLProperty `json:"URL"` 37 | } 38 | 39 | // A beta listing 40 | type Beta struct { 41 | ID string 42 | CreatedTime time.Time 43 | LastEditedTime time.Time 44 | Name string 45 | Announced time.Time 46 | Description string 47 | Category string 48 | Access string 49 | Type string 50 | URL string 51 | } 52 | 53 | // Safely extracts fields from a NotionBetasResult 54 | func parseBetaResult(r NotionBetasResult) (Beta, error) { 55 | var ( 56 | name, desc, category, access, betaType, u string 57 | announced time.Time 58 | ) 59 | 60 | if len(r.Properties.Name.Title) > 0 { 61 | name = r.Properties.Name.Title[0].PlainText 62 | } else { 63 | return Beta{}, fmt.Errorf("missing name for beta %s", r.ID) 64 | } 65 | 66 | if len(r.Properties.Description.RichText) > 0 { 67 | desc = r.Properties.Description.RichText[0].PlainText 68 | } else { 69 | return Beta{}, fmt.Errorf("missing description for beta %s", r.ID) 70 | } 71 | 72 | if len(r.Properties.Category.Select.Name) > 0 { 73 | category = r.Properties.Category.Select.Name 74 | } else { 75 | return Beta{}, fmt.Errorf("missing category for beta %s", r.ID) 76 | } 77 | 78 | if len(r.Properties.Access.Select.Name) > 0 { 79 | access = r.Properties.Access.Select.Name 80 | } else { 81 | return Beta{}, fmt.Errorf("missing access for beta %s", r.ID) 82 | } 83 | 84 | if len(r.Properties.Type.Select.Name) > 0 { 85 | betaType = r.Properties.Type.Select.Name 86 | } else { 87 | return Beta{}, fmt.Errorf("missing type for beta %s", r.ID) 88 | } 89 | 90 | if len(r.Properties.URL.URL) > 0 { 91 | u = r.Properties.URL.URL 92 | parsedUrl, err := url.Parse(u) 93 | if err != nil { 94 | return Beta{}, fmt.Errorf("invalid URL for beta %s: %w", r.ID, err) 95 | } 96 | 97 | query := parsedUrl.Query() 98 | query.Add("ref", "console.dev") 99 | parsedUrl.RawQuery = query.Encode() 100 | u = parsedUrl.String() 101 | } else { 102 | return Beta{}, fmt.Errorf("missing URL for beta %s", r.ID) 103 | } 104 | 105 | if len(r.Properties.Announced.Date.Start) > 0 { 106 | var err error 107 | announced, err = time.Parse("2006-01-02", r.Properties.Announced.Date.Start) 108 | if err != nil { 109 | slog.Error("Failed to parse announced date", "error", err) 110 | } 111 | } else { 112 | return Beta{}, fmt.Errorf("missing announced date for beta %s", r.ID) 113 | } 114 | 115 | return Beta{ 116 | ID: r.ID, 117 | CreatedTime: r.CreatedTime, 118 | LastEditedTime: r.LastEditedTime, 119 | Name: name, 120 | Announced: announced, 121 | Description: desc, 122 | Category: category, 123 | Access: access, 124 | Type: betaType, 125 | URL: u, 126 | }, nil 127 | } 128 | 129 | // Constructs the Notion query and returns a typed response. 130 | func (nc *NotionClient) queryDatabaseBetas( 131 | databaseID string, 132 | filter Filter, 133 | sorts []Sort, 134 | ) (*NotionBetasResponse, error) { 135 | queryReq := NotionQueryRequest{ 136 | Filter: filter, 137 | Sorts: sorts, 138 | } 139 | 140 | path := fmt.Sprintf("/databases/%s/query", databaseID) 141 | respBytes, err := nc.doRequest("POST", path, queryReq) 142 | if err != nil { 143 | return nil, fmt.Errorf("failed to query Notion: %w", err) 144 | } 145 | 146 | var notionResp NotionBetasResponse 147 | if err := json.Unmarshal(respBytes, ¬ionResp); err != nil { 148 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 149 | } 150 | 151 | return ¬ionResp, nil 152 | } 153 | 154 | // Returns the latest betas 155 | func (nc *NotionClient) GetLatestBetas() ([]Beta, *time.Time, error) { 156 | var betas []Beta 157 | getPreviousWeek := false 158 | 159 | var newsletterDate time.Time 160 | 161 | for attempts := 0; attempts < 2; attempts++ { 162 | newsletterDate = lastThursday(getPreviousWeek) 163 | 164 | filters := Filter{ 165 | And: []Condition{ 166 | { 167 | Property: "Newsletter", 168 | Date: &DateCondition{Equals: newsletterDate.Format(NotionDateFormat)}, 169 | }, 170 | }, 171 | } 172 | 173 | sorts := []Sort{ 174 | {Property: "Announced", Direction: "descending"}, 175 | } 176 | 177 | notionResponse, err := nc.queryDatabaseBetas(BetasDatabaseID, filters, sorts) 178 | if err != nil { 179 | return nil, nil, fmt.Errorf("failed to query database: %w", err) 180 | } 181 | 182 | if len(notionResponse.Results) == 0 { 183 | slog.Debug("No betas found, trying previous week") 184 | getPreviousWeek = true 185 | continue 186 | } 187 | 188 | betas = make([]Beta, 0, len(notionResponse.Results)) 189 | for _, res := range notionResponse.Results { 190 | beta, err := parseBetaResult(res) 191 | if err != nil { 192 | slog.Error("Failed to parse beta result", "error", err) 193 | continue 194 | } 195 | betas = append(betas, beta) 196 | } 197 | break 198 | } 199 | 200 | slog.Info("Fetched betas", slog.Int("count", len(betas))) 201 | 202 | return betas, &newsletterDate, nil 203 | } 204 | 205 | // Returns the betas scheduled for the next Thursday 206 | func (nc *NotionClient) GetNextBetas() ([]Beta, *time.Time, error) { 207 | var betas []Beta 208 | 209 | newsletterDate := nextThursday() 210 | 211 | filters := Filter{ 212 | And: []Condition{ 213 | { 214 | Property: "Newsletter", 215 | Date: &DateCondition{Equals: newsletterDate.Format(NotionDateFormat)}, 216 | }, 217 | }, 218 | } 219 | 220 | sorts := []Sort{ 221 | {Property: "Announced", Direction: "descending"}, 222 | } 223 | 224 | notionResponse, err := nc.queryDatabaseBetas(BetasDatabaseID, filters, sorts) 225 | if err != nil { 226 | return nil, nil, fmt.Errorf("failed to query database: %w", err) 227 | } 228 | 229 | if len(notionResponse.Results) == 0 { 230 | return nil, nil, fmt.Errorf("no betas found") 231 | } 232 | 233 | betas = make([]Beta, 0, len(notionResponse.Results)) 234 | for _, res := range notionResponse.Results { 235 | beta, err := parseBetaResult(res) 236 | if err != nil { 237 | slog.Error("Failed to parse beta result", "error", err) 238 | continue 239 | } 240 | betas = append(betas, beta) 241 | } 242 | 243 | slog.Info("Fetched betas", slog.Int("count", len(betas))) 244 | 245 | return betas, &newsletterDate, nil 246 | } 247 | -------------------------------------------------------------------------------- /internal/notion/query_common.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | ) 7 | 8 | // lastThursday returns the date of the last Thursday. 9 | // If today is Thursday, it returns today's date. 10 | // If the twoWeeksAgo flag is true, it returns the Thursday from two weeks ago. 11 | func lastThursday(twoWeeksAgo bool) time.Time { 12 | now := time.Now() 13 | 14 | // Calculate days since Thursday 15 | daysSinceThursday := (int(now.Weekday()) - int(time.Thursday) + 7) % 7 16 | slog.Debug("Days since Thursday", slog.Int("daysSinceThursday", daysSinceThursday)) 17 | 18 | // Determine last Thursday (or today if it's Thursday) 19 | targetThursday := now.AddDate(0, 0, -daysSinceThursday) 20 | 21 | slog.Debug("Target Thursday", slog.Time("targetThursday", targetThursday)) 22 | 23 | // Adjust for two weeks ago if the flag is set 24 | if twoWeeksAgo { 25 | targetThursday = targetThursday.AddDate(0, 0, -7) 26 | } 27 | 28 | return targetThursday 29 | } 30 | 31 | // nextThursday returns the date of the next Thursday 32 | func nextThursday() time.Time { 33 | now := time.Now() 34 | 35 | // Calculate days until Thursday 36 | daysUntilThursday := (int(time.Thursday) - int(now.Weekday()) + 7) % 7 37 | slog.Debug("Days until Thursday", slog.Int("daysUntilThursday", daysUntilThursday)) 38 | 39 | // Determine next Thursday 40 | targetThursday := now.AddDate(0, 0, daysUntilThursday) 41 | 42 | slog.Debug("Target Thursday", slog.Time("targetThursday", targetThursday)) 43 | 44 | return targetThursday 45 | } 46 | -------------------------------------------------------------------------------- /internal/notion/query_tools.go: -------------------------------------------------------------------------------- 1 | package notion 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/url" 8 | "sort" 9 | "time" 10 | ) 11 | 12 | const ( 13 | ToolsDatabaseID = "0c1974fd98c34abe91fdebb72260bb63" 14 | ) 15 | 16 | // NotionToolsResponse represents the top-level JSON response 17 | type NotionToolsResponse struct { 18 | Results []NotionToolsResult `json:"results"` 19 | } 20 | 21 | // NotionToolsResult represents each item in the results array 22 | type NotionToolsResult struct { 23 | ID string `json:"id"` 24 | CreatedTime time.Time `json:"created_time"` 25 | LastEditedTime time.Time `json:"last_edited_time"` 26 | Properties NotionToolProperties `json:"properties"` 27 | } 28 | 29 | // NotionToolProperties holds all properties for a single tool 30 | type NotionToolProperties struct { 31 | Name NotionTitleProperty `json:"Name"` 32 | Description NotionRichTextProperty `json:"Description"` 33 | Position NotionSelectProperty `json:"Position"` 34 | URL NotionURLProperty `json:"URL"` 35 | Like NotionRichTextProperty `json:"Like"` 36 | Dislike NotionRichTextProperty `json:"Dislike"` 37 | Investment NotionCheckboxProperty `json:"Investment"` 38 | } 39 | 40 | // Tool represents a tool with a review 41 | type Tool struct { 42 | ID string 43 | CreatedTime time.Time 44 | LastEditedTime time.Time 45 | Name string 46 | Description string 47 | Position string 48 | URL string 49 | Like string 50 | Dislike string 51 | } 52 | 53 | // parseToolResult safely extracts fields from a NotionToolsResult 54 | func parseToolResult(r NotionToolsResult) (Tool, error) { 55 | var ( 56 | name, desc, position, u, like, dislike string 57 | ) 58 | 59 | if len(r.Properties.Name.Title) > 0 { 60 | name = r.Properties.Name.Title[0].PlainText 61 | } else { 62 | return Tool{}, fmt.Errorf("missing name for tool %s", r.ID) 63 | } 64 | 65 | if len(r.Properties.Description.RichText) > 0 { 66 | desc = r.Properties.Description.RichText[0].PlainText 67 | } else { 68 | return Tool{}, fmt.Errorf("missing description for tool %s", r.ID) 69 | } 70 | 71 | if len(r.Properties.Position.Select.Name) > 0 { 72 | position = r.Properties.Position.Select.Name 73 | } else { 74 | return Tool{}, fmt.Errorf("missing position for tool %s", r.ID) 75 | } 76 | 77 | if len(r.Properties.URL.URL) > 0 { 78 | u = r.Properties.URL.URL 79 | parsedUrl, err := url.Parse(u) 80 | if err != nil { 81 | return Tool{}, fmt.Errorf("invalid URL for tool %s: %w", r.ID, err) 82 | } 83 | 84 | query := parsedUrl.Query() 85 | query.Add("ref", "console.dev") 86 | parsedUrl.RawQuery = query.Encode() 87 | u = parsedUrl.String() 88 | } else { 89 | return Tool{}, fmt.Errorf("missing URL for tool %s", r.ID) 90 | } 91 | 92 | if len(r.Properties.Like.RichText) > 0 { 93 | like = r.Properties.Like.RichText[0].PlainText 94 | } else { 95 | return Tool{}, fmt.Errorf("missing like for tool %s", r.ID) 96 | } 97 | 98 | if len(r.Properties.Dislike.RichText) > 0 { 99 | dislike = r.Properties.Dislike.RichText[0].PlainText 100 | } else { 101 | return Tool{}, fmt.Errorf("missing dislike for tool %s", r.ID) 102 | } 103 | 104 | return Tool{ 105 | ID: r.ID, 106 | CreatedTime: r.CreatedTime, 107 | LastEditedTime: r.LastEditedTime, 108 | Name: name, 109 | Description: desc, 110 | Position: position, 111 | URL: u, 112 | Like: like, 113 | Dislike: dislike, 114 | }, nil 115 | } 116 | 117 | // queryDatabaseTools constructs the Notion query and returns a typed response. 118 | func (nc *NotionClient) queryDatabaseTools( 119 | databaseID string, 120 | filter Filter, 121 | sorts []Sort, 122 | ) (*NotionToolsResponse, error) { 123 | queryReq := NotionQueryRequest{ 124 | Filter: filter, 125 | Sorts: sorts, 126 | } 127 | 128 | path := fmt.Sprintf("/databases/%s/query", databaseID) 129 | respBytes, err := nc.doRequest("POST", path, queryReq) 130 | if err != nil { 131 | return nil, fmt.Errorf("failed to query Notion: %w", err) 132 | } 133 | 134 | var notionResp NotionToolsResponse 135 | if err := json.Unmarshal(respBytes, ¬ionResp); err != nil { 136 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 137 | } 138 | 139 | return ¬ionResp, nil 140 | } 141 | 142 | // GetLatestTools returns the latest tool reviews 143 | func (nc *NotionClient) GetLatestTools() ([]Tool, *time.Time, error) { 144 | var tools []Tool 145 | getPreviousWeek := false 146 | 147 | var newsletterDate time.Time 148 | 149 | for attempts := 0; attempts < 2; attempts++ { 150 | newsletterDate := lastThursday(getPreviousWeek) 151 | 152 | filters := Filter{ 153 | And: []Condition{ 154 | { 155 | Property: "Status", 156 | Status: &StatusCondition{Equals: "Selected"}, 157 | }, 158 | { 159 | Property: "Newsletter", 160 | Date: &DateCondition{Equals: newsletterDate.Format(NotionDateFormat)}, 161 | }, 162 | }, 163 | } 164 | 165 | sorts := []Sort{ 166 | {Property: "Position", Direction: "descending"}, 167 | } 168 | 169 | notionResponse, err := nc.queryDatabaseTools(ToolsDatabaseID, filters, sorts) 170 | if err != nil { 171 | return nil, nil, fmt.Errorf("failed to query database: %w", err) 172 | } 173 | 174 | if len(notionResponse.Results) == 0 { 175 | slog.Debug("No tools found, trying previous week") 176 | getPreviousWeek = true 177 | continue 178 | } 179 | 180 | tools = make([]Tool, 0, len(notionResponse.Results)) 181 | for _, res := range notionResponse.Results { 182 | tool, err := parseToolResult(res) 183 | if err != nil { 184 | return nil, nil, fmt.Errorf("failed to parse tool: %w", err) 185 | } 186 | tools = append(tools, tool) 187 | } 188 | break 189 | } 190 | 191 | // Sort by position in ascending order 192 | sort.Slice(tools, func(i, j int) bool { 193 | return tools[i].Position < tools[j].Position 194 | }) 195 | 196 | slog.Info("Fetched tools", "count", slog.Int("count", len(tools))) 197 | 198 | return tools, &newsletterDate, nil 199 | } 200 | 201 | // GetLatestTools returns the tool reviews scheduled for next Thursday 202 | func (nc *NotionClient) GetNextTools() ([]Tool, *time.Time, error) { 203 | var tools []Tool 204 | 205 | newsletterDate := nextThursday() 206 | 207 | filters := Filter{ 208 | And: []Condition{ 209 | { 210 | Property: "Status", 211 | Status: &StatusCondition{Equals: "Selected"}, 212 | }, 213 | { 214 | Property: "Newsletter", 215 | Date: &DateCondition{Equals: newsletterDate.Format(NotionDateFormat)}, 216 | }, 217 | }, 218 | } 219 | 220 | sorts := []Sort{ 221 | {Property: "Position", Direction: "descending"}, 222 | } 223 | 224 | notionResponse, err := nc.queryDatabaseTools(ToolsDatabaseID, filters, sorts) 225 | if err != nil { 226 | return nil, nil, fmt.Errorf("failed to query database: %w", err) 227 | } 228 | 229 | if len(notionResponse.Results) == 0 { 230 | return nil, nil, fmt.Errorf("no tools found") 231 | } 232 | 233 | tools = make([]Tool, 0, len(notionResponse.Results)) 234 | for _, res := range notionResponse.Results { 235 | tool, err := parseToolResult(res) 236 | if err != nil { 237 | return nil, nil, fmt.Errorf("failed to parse tool: %w", err) 238 | } 239 | tools = append(tools, tool) 240 | } 241 | 242 | // Sort by position in ascending order 243 | sort.Slice(tools, func(i, j int) bool { 244 | return tools[i].Position < tools[j].Position 245 | }) 246 | 247 | slog.Info("Fetched tools", "count", slog.Int("count", len(tools))) 248 | 249 | return tools, &newsletterDate, nil 250 | } 251 | -------------------------------------------------------------------------------- /internal/rss/rss.go: -------------------------------------------------------------------------------- 1 | package rss 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | type Rss struct { 9 | XMLName xml.Name `xml:"rss"` 10 | Version string `xml:"version,attr"` 11 | Title string `xml:"channel>title"` 12 | Description string `xml:"channel>description"` 13 | Link string `xml:"channel>link"` 14 | PubDate string `xml:"channel>pubDate"` 15 | Items []RssItem `xml:"channel>item"` 16 | } 17 | 18 | type Description struct { 19 | XMLName xml.Name `xml:"description"` 20 | Text string `xml:",cdata"` 21 | } 22 | 23 | type RssItem struct { 24 | Title string `xml:"title"` 25 | Description Description `xml:"description"` 26 | Link string `xml:"link"` 27 | PubDate string `xml:"pubDate"` 28 | } 29 | 30 | func NewRSS(title string, description string, pubDate time.Time, items []RssItem) Rss { 31 | return Rss{ 32 | Version: "2.0", 33 | Title: title, 34 | Description: description, 35 | Link: "https://console.dev", 36 | PubDate: pubDate.Format(time.RFC1123Z), 37 | Items: items, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "github.com/consoledotdev/home/internal/cache" 13 | "github.com/consoledotdev/home/internal/handlers" 14 | "github.com/consoledotdev/home/internal/middleware" 15 | "github.com/consoledotdev/home/internal/notion" 16 | "github.com/consoledotdev/home/web" 17 | ) 18 | 19 | //go:generate npm run build 20 | 21 | //go:embed web/templates 22 | var templates embed.FS 23 | 24 | //go:embed all:web/static 25 | var static embed.FS 26 | 27 | var debug bool 28 | 29 | func init() { 30 | _, jsonLogger := os.LookupEnv("JSON_LOGGER") 31 | _, debug = os.LookupEnv("DEBUG") 32 | 33 | var programLevel slog.Level 34 | if debug { 35 | programLevel = slog.LevelDebug 36 | } 37 | 38 | if jsonLogger { 39 | jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ 40 | Level: programLevel, 41 | }) 42 | slog.SetDefault(slog.New(jsonHandler)) 43 | } else { 44 | textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 45 | Level: programLevel, 46 | }) 47 | slog.SetDefault(slog.New(textHandler)) 48 | } 49 | 50 | slog.Info("Logger initialized", slog.Bool("debug", debug)) 51 | } 52 | 53 | func main() { 54 | // Notion client 55 | notionClient, err := notion.NewNotionClient(os.Getenv("NOTION_SECRET")) 56 | if err != nil { 57 | slog.Error("Failed to create Notion client", "error", err) 58 | return 59 | } 60 | 61 | c := cache.NewMemoryCache() 62 | 63 | web.NewTemplates(templates) 64 | 65 | // StaleCache that revalidates after midnight 66 | swrCache := &cache.SwrCache{ 67 | Cache: c, 68 | FetchToolsFunc: notionClient.GetLatestTools, 69 | FetchBetasFunc: notionClient.GetLatestBetas, 70 | } 71 | 72 | if err := initCache(swrCache); err != nil { 73 | slog.Warn("Failed to initialize cache", "error", err) 74 | } 75 | 76 | // Web server 77 | port := os.Getenv("PORT") 78 | if port == "" { 79 | port = "8080" 80 | } 81 | addr := ":" + port 82 | 83 | chain := &middleware.Chain{} 84 | chain.Use(middleware.RecoverMiddleware) // First middleware (innermost) 85 | chain.Use(middleware.LoggerMiddleware) // Last middleware (outermost) 86 | 87 | mux := http.NewServeMux() 88 | 89 | // Use an embedded filesystem rooted at "web/static" 90 | fs, err := fs.Sub(static, "web/static") 91 | if err != nil { 92 | slog.Error("Failed to create sub filesystem", "error", err) 93 | return 94 | } 95 | 96 | // Serve files from the embedded /web/static directory at /static 97 | fileServer := http.FileServer(http.FS(fs)) 98 | mux.Handle("GET /static/", chain.Then(http.StripPrefix("/static/", fileServer))) 99 | 100 | // Serve favicon.ico and robots.txt 101 | mux.Handle("GET /favicon.ico", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { 102 | data, err := static.ReadFile("web/static/img/favicon.ico") 103 | if err != nil { 104 | http.NotFound(w, r) 105 | return 106 | } 107 | w.Write(data) 108 | })) 109 | mux.Handle("GET /robots.txt", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { 110 | data, err := static.ReadFile("web/static/robots.txt") 111 | if err != nil { 112 | http.NotFound(w, r) 113 | return 114 | } 115 | w.Write(data) 116 | })) 117 | 118 | mux.Handle("GET /health", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { 119 | w.Write([]byte(`OK`)) 120 | })) 121 | 122 | mux.Handle("GET /{$}", chain.Then(handlers.RootHandler(swrCache))) 123 | mux.Handle("GET /rss.xml", chain.Then(handlers.RssEverythingHandler(swrCache))) 124 | mux.Handle("GET /betas/rss.xml", chain.Then(handlers.RssBetasHandler(swrCache))) 125 | mux.Handle("GET /tools/rss.xml", chain.Then(handlers.RssToolsHandler(swrCache))) 126 | mux.Handle("GET /advertise", chain.Then(handlers.AdvertiseHandler())) 127 | mux.Handle("GET /privacy", chain.Then(handlers.PrivacyHandler())) 128 | mux.Handle("GET /confirm", chain.Then(handlers.ConfirmHandler())) 129 | // Mailchimp doesn't support removing the trailing slash from links 130 | mux.Handle("GET /confirm/", chain.Then(handlers.ConfirmHandler())) 131 | mux.Handle("GET /selection-criteria", chain.Then(handlers.SelectionCriteriaHandler())) 132 | 133 | // Only available when running locally 134 | if debug { 135 | mux.Handle("GET /generate", chain.Then(handlers.GenerateHandler(notionClient))) 136 | } 137 | 138 | // Catch-all 404 handler - must be last 139 | mux.Handle("GET /", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { 140 | w.WriteHeader(http.StatusNotFound) 141 | web.Render(w, "404.html", nil) 142 | })) 143 | 144 | server := &http.Server{ 145 | Addr: fmt.Sprintf(":%s", port), 146 | Handler: mux, 147 | // Recommended timeouts from 148 | // https://blog.cloudflare.com/exposing-go-on-the-internet/ 149 | ReadTimeout: 5 * time.Second, 150 | WriteTimeout: 10 * time.Second, 151 | IdleTimeout: 120 * time.Second, 152 | } 153 | 154 | slog.Info("Server listening", "addr", addr) 155 | 156 | if err := server.ListenAndServe(); err != nil { 157 | slog.Error("Server failed to start", "error", err) 158 | } 159 | } 160 | 161 | // initCache ensures the cache has data on startup 162 | func initCache(staleCache *cache.SwrCache) error { 163 | _, _, _, err := staleCache.GetToolsAndBetas() 164 | return err 165 | } 166 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console.dev", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "console.dev", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "tailwindcss": "4.1.5" 12 | } 13 | }, 14 | "node_modules/tailwindcss": { 15 | "version": "4.1.5", 16 | "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz", 17 | "integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==", 18 | "license": "MIT" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "console.dev", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build": "npx @tailwindcss/cli@4.1.5 -i web/static/css/input.css -o web/static/css/output.css" 6 | }, 7 | "dependencies": { 8 | "tailwindcss": "4.1.5" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkmode: "media", 4 | content: ["./web/templates/*.html"], 5 | theme: { 6 | extend: { 7 | colors: { 8 | 'console-dark': '#0a0a0b', 9 | 'console-category': '#ffa6ff', 10 | }, 11 | fontFamily: { 12 | sans: ['Rubik', 'sans-serif'], 13 | mono: ['Consolas', 'monospace'], 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | 20 | -------------------------------------------------------------------------------- /web/static/css/input.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @config '../../../tailwind.config.js'; 4 | 5 | @theme { 6 | --color-console-dark: #0a0a0b; 7 | --color-console-category: #ffa6ff; 8 | 9 | --font-sans: 'Rubik', sans-serif; 10 | --font-mono: 'Consolas', monospace; 11 | } 12 | 13 | @font-face { 14 | font-family: 'Rubik'; 15 | font-style: normal; 16 | font-weight: 400; 17 | font-display: swap; 18 | src: url('/static/fonts/Rubik-Regular.woff2') format('woff2'); 19 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Rubik'; 24 | font-style: normal; 25 | font-weight: 500; 26 | font-display: swap; 27 | src: url('/static/fonts/Rubik-Medium.woff2') format('woff2'); 28 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 29 | } 30 | 31 | @font-face { 32 | font-family: 'Rubik'; 33 | font-style: normal; 34 | font-weight: 700; 35 | font-display: swap; 36 | src: url('/static/fonts/Rubik-Bold.woff2') format('woff2'); 37 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 38 | } 39 | 40 | .logo-svg-animated-explode { 41 | width: 3rem; 42 | } 43 | 44 | .plane-one { 45 | stroke-width: 4; 46 | stroke-dasharray: 379; 47 | transform: skewX(-26.56deg) translate(17.53px, 0); 48 | animation: p1-anim 1.6s cubic-bezier(0.41, 0.14, 0.35, 0.99) forwards; 49 | } 50 | 51 | .plane-two { 52 | stroke-width: 4; 53 | stroke-dasharray: 379; 54 | transform: skewX(-26.56deg) translate(0, 15.68px); 55 | animation: p2-anim 1.6s cubic-bezier(0.41, 0.14, 0.35, 0.99) forwards; 56 | } 57 | 58 | .plane-three { 59 | stroke-width: 4; 60 | stroke-dasharray: 379; 61 | transform: skewX(-26.56deg) translate(-20px, -20px); 62 | animation: p3-anim 1.6s cubic-bezier(0.41, 0.14, 0.35, 0.99) forwards; 63 | } 64 | 65 | .prompt { 66 | fill: #ffffff; 67 | animation: prompt-in 0.25s cubic-bezier(0.41, 0.14, 0.35, 0.99) forwards; 68 | } 69 | 70 | @keyframes p1-anim { 71 | 0% { 72 | stroke-dashoffset: 379; 73 | } 74 | 75 | 55% { 76 | stroke-dashoffset: 0; 77 | } 78 | 79 | 100% { 80 | stroke: #5269FF; 81 | transform: skewX(-26.56deg) translate(17.53px, 0); 82 | } 83 | } 84 | 85 | @keyframes p2-anim { 86 | 0% { 87 | stroke-dashoffset: 379; 88 | opacity: 0; 89 | } 90 | 91 | 55% { 92 | stroke-dashoffset: 0; 93 | } 94 | 95 | 100% { 96 | stroke: #F561F5; 97 | opacity: 1; 98 | transform: skewX(-26.56deg) translate(0, 15.68px); 99 | } 100 | } 101 | 102 | @keyframes p3-anim { 103 | 0% { 104 | stroke-dashoffset: 379; 105 | opacity: 0; 106 | } 107 | 108 | 55% { 109 | stroke-dashoffset: 0; 110 | } 111 | 112 | 100% { 113 | stroke: #01BC90; 114 | opacity: 1; 115 | transform: skewX(-26.56deg) translate(-20px, -20px); 116 | } 117 | } 118 | 119 | @keyframes prompt-in { 120 | 0% { 121 | opacity: 0; 122 | } 123 | 124 | 100% { 125 | opacity: 1; 126 | } 127 | } -------------------------------------------------------------------------------- /web/static/css/new.css: -------------------------------------------------------------------------------- 1 | /* 2 | https://newcss.net/ 3 | 4 | Used for the Mailchimp hosted pages. 5 | */ 6 | 7 | :root { 8 | --nc-font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 9 | --nc-font-mono: Consolas, monaco, "Ubuntu Mono", "Liberation Mono", "Courier New", Courier, monospace; 10 | 11 | /* Light theme 12 | --nc-tx-1: #000000; 13 | --nc-tx-2: #1A1A1A; 14 | --nc-bg-1: #FFFFFF; 15 | --nc-bg-2: #F6F8FA; 16 | --nc-bg-3: #E5E7EB; 17 | --nc-lk-1: #0070F3; 18 | --nc-lk-2: #0366D6; 19 | --nc-lk-tx: #FFFFFF; 20 | --nc-ac-1: #79FFE1; 21 | --nc-ac-tx: #0C4047; 22 | 23 | /* Dark theme */ 24 | --nc-tx-1: #ffffff; 25 | --nc-tx-2: #eeeeee; 26 | --nc-bg-1: #000000; 27 | --nc-bg-2: #111111; 28 | --nc-bg-3: #222222; 29 | --nc-lk-1: #3291ff; 30 | --nc-lk-2: #0070f3; 31 | --nc-lk-tx: #ffffff; 32 | --nc-ac-1: #7928ca; 33 | --nc-ac-tx: #ffffff; 34 | } 35 | 36 | * { 37 | /* Reset margins and padding */ 38 | margin: 0; 39 | padding: 0; 40 | } 41 | 42 | address, 43 | area, 44 | article, 45 | aside, 46 | audio, 47 | blockquote, 48 | datalist, 49 | details, 50 | dl, 51 | fieldset, 52 | figure, 53 | form, 54 | input, 55 | iframe, 56 | img, 57 | meter, 58 | nav, 59 | ol, 60 | optgroup, 61 | option, 62 | output, 63 | p, 64 | pre, 65 | progress, 66 | ruby, 67 | section, 68 | table, 69 | textarea, 70 | ul, 71 | video { 72 | /* Margins for most elements */ 73 | margin-bottom: 1rem; 74 | } 75 | 76 | html, 77 | input, 78 | select, 79 | button { 80 | /* Set body font family and some finicky elements */ 81 | font-family: var(--nc-font-sans); 82 | } 83 | 84 | body { 85 | /* Center body in page */ 86 | margin: 0 auto; 87 | max-width: 750px; 88 | padding: 2rem; 89 | border-radius: 6px; 90 | overflow-x: hidden; 91 | word-break: break-word; 92 | overflow-wrap: break-word; 93 | background: var(--nc-bg-1); 94 | 95 | /* Main body text */ 96 | color: var(--nc-tx-2); 97 | font-size: 1.03rem; 98 | line-height: 1.5; 99 | } 100 | 101 | ::selection { 102 | /* Set background color for selected text */ 103 | background: var(--nc-ac-1); 104 | color: var(--nc-ac-tx); 105 | } 106 | 107 | h1, 108 | h2, 109 | h3, 110 | h4, 111 | h5, 112 | h6 { 113 | line-height: 1; 114 | color: var(--nc-tx-1); 115 | padding-top: 0.875rem; 116 | } 117 | 118 | h1, 119 | h2, 120 | h3 { 121 | color: var(--nc-tx-1); 122 | padding-bottom: 2px; 123 | margin-bottom: 8px; 124 | border-bottom: 1px solid var(--nc-bg-2); 125 | } 126 | 127 | h4, 128 | h5, 129 | h6 { 130 | margin-bottom: 0.3rem; 131 | } 132 | 133 | h1 { 134 | font-size: 2.25rem; 135 | } 136 | 137 | h2 { 138 | font-size: 1.85rem; 139 | } 140 | 141 | h3 { 142 | font-size: 1.55rem; 143 | } 144 | 145 | h4 { 146 | font-size: 1.25rem; 147 | } 148 | 149 | h5 { 150 | font-size: 1rem; 151 | } 152 | 153 | h6 { 154 | font-size: 0.875rem; 155 | } 156 | 157 | a { 158 | color: var(--nc-lk-1); 159 | } 160 | 161 | a:hover { 162 | color: var(--nc-lk-2); 163 | } 164 | 165 | abbr:hover { 166 | /* Set the '?' cursor while hovering an abbreviation */ 167 | cursor: help; 168 | } 169 | 170 | blockquote { 171 | padding: 1.5rem; 172 | background: var(--nc-bg-2); 173 | border-left: 5px solid var(--nc-bg-3); 174 | } 175 | 176 | abbr { 177 | cursor: help; 178 | } 179 | 180 | blockquote *:last-child { 181 | padding-bottom: 0; 182 | margin-bottom: 0; 183 | } 184 | 185 | header { 186 | background: var(--nc-bg-2); 187 | border-bottom: 1px solid var(--nc-bg-3); 188 | padding: 2rem 1.5rem; 189 | 190 | /* This sets the right and left margins to cancel out the body's margins. It's width is still the same, but the background stretches across the page's width. */ 191 | 192 | margin: -2rem calc(0px - (50vw - 50%)) 2rem; 193 | 194 | /* Shorthand for: 195 | 196 | margin-top: -2rem; 197 | margin-bottom: 2rem; 198 | 199 | margin-left: calc(0px - (50vw - 50%)); 200 | margin-right: calc(0px - (50vw - 50%)); */ 201 | 202 | padding-left: calc(50vw - 50%); 203 | padding-right: calc(50vw - 50%); 204 | } 205 | 206 | header h1, 207 | header h2, 208 | header h3 { 209 | padding-bottom: 0; 210 | border-bottom: 0; 211 | } 212 | 213 | header>*:first-child { 214 | margin-top: 0; 215 | padding-top: 0; 216 | } 217 | 218 | header>*:last-child { 219 | margin-bottom: -0.5em; 220 | } 221 | 222 | a button, 223 | button, 224 | input[type="submit"], 225 | input[type="reset"], 226 | input[type="button"] { 227 | font-size: 1rem; 228 | display: inline-block; 229 | padding: 0.375rem 0.75rem; 230 | text-align: center; 231 | text-decoration: none; 232 | white-space: nowrap; 233 | background: var(--nc-lk-1); 234 | color: var(--nc-lk-tx); 235 | border: 0; 236 | border-radius: 0.25rem; 237 | box-sizing: border-box; 238 | cursor: pointer; 239 | color: var(--nc-lk-tx); 240 | } 241 | 242 | a button[disabled], 243 | button[disabled], 244 | input[type="submit"][disabled], 245 | input[type="reset"][disabled], 246 | input[type="button"][disabled] { 247 | cursor: default; 248 | opacity: 0.5; 249 | 250 | /* Set the [X] cursor while hovering a disabled link */ 251 | cursor: not-allowed; 252 | } 253 | 254 | .button:focus, 255 | .button:enabled:hover, 256 | button:focus, 257 | button:enabled:hover, 258 | input[type="submit"]:focus, 259 | input[type="submit"]:enabled:hover, 260 | input[type="reset"]:focus, 261 | input[type="reset"]:enabled:hover, 262 | input[type="button"]:focus, 263 | input[type="button"]:enabled:hover { 264 | background: var(--nc-lk-2); 265 | } 266 | 267 | code, 268 | pre, 269 | kbd, 270 | samp { 271 | /* Set the font family for monospaced elements */ 272 | font-family: var(--nc-font-mono); 273 | } 274 | 275 | code, 276 | samp, 277 | kbd, 278 | pre { 279 | /* The main preformatted style. This is changed slightly across different cases. */ 280 | background: var(--nc-bg-2); 281 | border: 1px solid var(--nc-bg-3); 282 | border-radius: 4px; 283 | padding: 3px 6px; 284 | /* ↓ font-size is relative to containing element, so it scales for titles*/ 285 | font-size: 0.9em; 286 | } 287 | 288 | kbd { 289 | /* Makes the kbd element look like a keyboard key */ 290 | border-bottom: 3px solid var(--nc-bg-3); 291 | } 292 | 293 | pre { 294 | padding: 1rem 1.4rem; 295 | max-width: 100%; 296 | overflow: auto; 297 | } 298 | 299 | pre code { 300 | /* When is in a , reset it's formatting to blend in */
301 | background: inherit;
302 | font-size: inherit;
303 | color: inherit;
304 | border: 0;
305 | padding: 0;
306 | margin: 0;
307 | }
308 |
309 | code pre {
310 | /* When is in a , reset it's formatting to blend in */
311 | display: inline;
312 | background: inherit;
313 | font-size: inherit;
314 | color: inherit;
315 | border: 0;
316 | padding: 0;
317 | margin: 0;
318 | }
319 |
320 | details {
321 | /* Make the look more "clickable" */
322 | padding: 0.6rem 1rem;
323 | background: var(--nc-bg-2);
324 | border: 1px solid var(--nc-bg-3);
325 | border-radius: 4px;
326 | }
327 |
328 | summary {
329 | /* Makes the look more like a "clickable" link with the pointer cursor */
330 | cursor: pointer;
331 | font-weight: bold;
332 | }
333 |
334 | details[open] {
335 | /* Adjust the padding while open */
336 | padding-bottom: 0.75rem;
337 | }
338 |
339 | details[open] summary {
340 | /* Adjust the padding while open */
341 | margin-bottom: 6px;
342 | }
343 |
344 | details[open]>*:last-child {
345 | /* Resets the bottom margin of the last element in the while is opened. This prevents double margins/paddings. */
346 | margin-bottom: 0;
347 | }
348 |
349 | dt {
350 | font-weight: bold;
351 | }
352 |
353 | dd::before {
354 | /* Add an arrow to data table definitions */
355 | content: "→ ";
356 | }
357 |
358 | hr {
359 | /* Reset the border of the
separator, then set a better line */
360 | border: 0;
361 | border-bottom: 1px solid var(--nc-bg-3);
362 | margin: 1rem auto;
363 | }
364 |
365 | fieldset {
366 | margin-top: 1rem;
367 | padding: 2rem;
368 | border: 1px solid var(--nc-bg-3);
369 | border-radius: 4px;
370 | }
371 |
372 | legend {
373 | padding: auto 0.5rem;
374 | }
375 |
376 | table {
377 | /* border-collapse sets the table's elements to share borders, rather than floating as separate "boxes". */
378 | border-collapse: collapse;
379 | width: 100%;
380 | }
381 |
382 | td,
383 | th {
384 | border: 1px solid var(--nc-bg-3);
385 | text-align: left;
386 | padding: 0.5rem;
387 | }
388 |
389 | th {
390 | background: var(--nc-bg-2);
391 | }
392 |
393 | tr:nth-child(even) {
394 | /* Set every other cell slightly darker. Improves readability. */
395 | background: var(--nc-bg-2);
396 | }
397 |
398 | table caption {
399 | font-weight: bold;
400 | margin-bottom: 0.5rem;
401 | }
402 |
403 | textarea {
404 | /* Don't let the