diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4c07a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +fly.toml +/node_modules +*.log +.DS_Store +.env +/.cache +/public/build +/build diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..addb8e5 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,12 @@ +{ + "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], + "ignorePatterns": ["**/*.js", "**/**/*.js"], + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "eqeqeq": "error", + "no-param-reassign": "error", + "no-return-assign": "error", + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6bd7d6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + - run: npm install + - name: Check format + run: npm run fmt:check + - name: Typecheck + run: npm run tsc + - name: Lint + run: npm run lint + - name: Unit tests + run: npm test run + - name: Build + run: npm run build diff --git a/.github/workflows/licence-check.yml b/.github/workflows/licence-check.yml new file mode 100644 index 0000000..7e43614 --- /dev/null +++ b/.github/workflows/licence-check.yml @@ -0,0 +1,18 @@ +# To run this check locally, install SkyWalking Eyes somehow +# (https://github.com/apache/skywalking-eyes). On macOS you can `brew install +# license-eye` and run `license-eye header check` or `license-eye header fix`. + +name: license-check + +on: + push: + branches: [main] + pull_request: + +jobs: + license: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Check License Header + uses: apache/skywalking-eyes/header@5dfa68f93380a5e57259faaf95088b7f133b5778 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..31095c4 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,34 @@ +name: Playwright Tests + +# This is an unusual job because it's triggered by deploy events rather than +# PR/push. The if condition means we only run on deployment_status events where +# the status is success, i.e., we only run after Vercel deploy has succeeded. + +on: + deployment_status: +jobs: + playwright: + if: + github.event_name == 'deployment_status' && github.event.deployment_status.state == + 'success' + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + env: + BASE_URL: ${{ github.event.deployment_status.target_url }} + - uses: actions/upload-artifact@v3 + if: always() + with: + name: test-results + path: test-results/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2365ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +.terraform + +.cache +.env +.vercel +.output + +/build/ +/public/build/ +/api/index.js +/api/index.js.map +/api/_assets +tsconfig.tsbuildinfo +test-results/ + +.DS_Store + +/app/components/icons diff --git a/.infra/.terraform.lock.hcl b/.infra/.terraform.lock.hcl new file mode 100644 index 0000000..bdbb215 --- /dev/null +++ b/.infra/.terraform.lock.hcl @@ -0,0 +1,40 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "4.38.0" + hashes = [ + "h1:YaAiiWx0bbMKk/TfLn9XIeGX4/fEQaZT7Z1Q38vTGoM=", + "zh:019ee2c826fa9e503b116909d8ef95e190f7e54078a72b3057a3e1f65b2ae0c2", + "zh:2895bdd0036032ca4667cddecec2372d9ba190be8ecf2527d705ef3fb3f5c2fb", + "zh:6bf593e604619fb413b7869ebe72de0ff883860cfc85d58ae06eb2b7cf088a6d", + "zh:72d2ca1f36062a250a6b499363e3eb4c4b983a415b7c31c5ee7dab4dbeeaf020", + "zh:7971431d90ecfdf3c50027f38447628801b77d03738717d6b22fb123e27a3dfc", + "zh:821be1a1f709e6ef264a98339565609f5cfeb25b32ad6af5bf4b562fde5677e8", + "zh:8b3811426eefd3c47c4de2990d129c809bc838a08a18b3497312121f3a482e73", + "zh:a5e3c3aad4e7873014e4773fd8c261f74abc5cf6ab417c0fce3da2ed93154451", + "zh:bb026e3c79408625fe013337cf7d7608e20b2b1c7b02d38a10780e191c08e56c", + "zh:defa59b317eea43360a8303440398ed02717d8f29880ffad407ca7ebb63938fd", + "zh:f4883b304c54dd0480af5463b3581b01bc43d9f573cfd9179d7e4d8b6af27147", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.4.3" + hashes = [ + "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=", + "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752", + "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b", + "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3", + "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5", + "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda", + "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6", + "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1", + "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d", + "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8", + "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93", + ] +} diff --git a/.infra/README.md b/.infra/README.md new file mode 100644 index 0000000..7451a38 --- /dev/null +++ b/.infra/README.md @@ -0,0 +1,63 @@ +# RFD Image Hosting + +Images for the RFD frontend are stored external to the codebase, and the configuration +stored here defines the GCP infrastructure used to host them. + +For images we want to be able to: + +1. Require a user to authenticate to access an image even if they know the path to the image +2. Serve images without requiring a trip through the frontend server + +Generally any CDN can satisfy point 2 alone. Combining both requirements though is more +difficult without requiring that the CDN understanding user authentication. The solution +here does not fully satisfy the above requirements, but attempts to get close. Images served +from the generated infrastructure require that the image url contain a valid signature that +encodes the image being requested, how long the url is valid for, and the key used to sign +the request. Once a url expires it will begin responding with a 403 error. This allows for +the paths to images to be publicly known without providing generic public access. + +Note: Infrastructure configuration is stored in this repository until a point in time where +we have RFD infrastructure that is separate from `cio`. At that point, this infrastructure +should be owned by the RFD service. + +### GCP Infrastructure + +Image storage and serving is handled by +[Cloud CDN](https://cloud.google.com/cdn/docs/using-signed-urls), backed by +[Cloud Storage](https://cloud.google.com/storage). + +``` +┌─────────────────┐ +│ Cloud CDN ├─ Cache +└────────┬────────┘ +┌────────┴────────┐ +│ Load Balancer │ +└────────┬────────┘ +┌────────┴────────┐ +│ Backend Bucket ├─ Validate signature +└────────┬────────┘ +┌────────┴────────┐ +│ Cloud Storage │ +└─────────────────┘ +``` + +### Deploy + +There are a few steps to deploying this infrastructure: + +1. Run `create_cert.sh ` to generate a TLS certificate to attach to the load + balancer. We do not create a certificate during Terraform step to prevent the private key + from being written to the tfstate. + https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_ssl_certificate +2. Run `terraform apply`. If you changed the name of the certificate created in + `create_cert.sh` or the project to be deployed to, then ensure that the `cert` and + `project` variables are specified. +3. Run `add_signing_key.sh ` to generate a signing key. + This wil output the secret signing key for generating signed urls. Ensure that this key + is stored securely, it can not be recovered. We do not generate this key during the + Terraform step as doing so would write the key to the tfstate. + https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_backend_service_signed_url_key +4. Run `activate.sh ` to grant permission for Cloud CDN to read + from the generated storage bucket. +5. Create a DNS record pointing `static.rfd.shared.oxide.computer` (unless you used a + different domain name) to the IP address allocated by executing the Terraform config. diff --git a/.infra/activate.sh b/.infra/activate.sh new file mode 100755 index 0000000..044b848 --- /dev/null +++ b/.infra/activate.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# Copyright Oxide Computer Company + + +if [ -z "$1" ] || [ -z "$2" ] +then + echo "A project and storage bucket name must be supplied" + exit 1 +fi + +PROJECT=$1 +BUCKET=$2 + +PROJECTNUMBER=$(gcloud projects list \ + --filter="$(gcloud config get-value project --project $PROJECT)" \ + --format="value(PROJECT_NUMBER)" \ + --project $PROJECT +) + +gsutil iam ch \ + serviceAccount:service-$PROJECTNUMBER@cloud-cdn-fill.iam.gserviceaccount.com:objectViewer \ + gs://$BUCKET diff --git a/.infra/add_signing_key.sh b/.infra/add_signing_key.sh new file mode 100755 index 0000000..0a5692f --- /dev/null +++ b/.infra/add_signing_key.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# Copyright Oxide Computer Company + + +if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ] +then + echo "A project, backend bucket, and key name must be supplied" + exit 1 +fi + +PROJECT=$1 +BACKEND=$2 +KEYNAME=$3 + +# Key generation uses the recommendation from GCP +# See: https://cloud.google.com/cdn/docs/using-signed-urls#configuring_signed_request_keys +KEY=$(head -c 16 /dev/urandom | base64 | tr +/ -_) +KEYFILE=$(head -c 16 /dev/urandom | base64 | tr +/ -_) + +echo $KEY > $KEYFILE + +gcloud compute backend-buckets \ + add-signed-url-key $BACKEND \ + --key-name $KEYNAME \ + --key-file $KEYFILE \ + --project $PROJECT + +echo "Added signing key $KEYNAME to $BACKEND. Ensure that this key is stored securely, it can not be recovered. In the case that it is lost a new key must be created." +echo "Key: $KEY" + +rm $KEYFILE \ No newline at end of file diff --git a/.infra/create_cert.sh b/.infra/create_cert.sh new file mode 100755 index 0000000..30b9b60 --- /dev/null +++ b/.infra/create_cert.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# Copyright Oxide Computer Company + + +if [ -z "$1" ] +then + echo "A project must be supplied" + exit 1 +fi + +PROJECT=$1 + +gcloud compute ssl-certificates create rfd-static-cert \ + --description="Static asset serving for RFD frontend" \ + --domains="static.rfd.shared.oxide.computer" \ + --global \ + --project $PROJECT \ No newline at end of file diff --git a/.infra/gcp.tf b/.infra/gcp.tf new file mode 100644 index 0000000..624690e --- /dev/null +++ b/.infra/gcp.tf @@ -0,0 +1,17 @@ +provider "google" { + project = var.project + region = "us-central1" + zone = "us-central1-c" +} + +variable "project" { + default = "websites-326710" +} + +variable "prefix" { + default = "rfd-static-assets" +} + +variable "cert" { + default = "rfd-static-cert" +} diff --git a/.infra/static_assets.tf b/.infra/static_assets.tf new file mode 100644 index 0000000..cf87333 --- /dev/null +++ b/.infra/static_assets.tf @@ -0,0 +1,71 @@ +# This configuration will spin up the core components for hosting the static assets needed by the +# RFD frontend. It does not: +# 1. Create the url signing key for the bucket backend +# 2. Grant access for Cloud CDN to access objects in the storage bucket +# These steps should be performed out of band to ensure that the signing key is not stored in the +# terraform state, and that the Cloud CDN does not have access until a signing key is set + +# These resources must be created prior to deployment and their state must not be stored as they +# hold private data +data "google_compute_ssl_certificate" "rfd_static_cert" { + name = var.cert +} + +# A random suffix to avoid colliding bucket names +resource "random_id" "bucket_suffix" { + byte_length = 8 +} + +# The storage bucket for holding static assets used by the frontend +resource "google_storage_bucket" "storage_bucket" { + name = "${var.prefix}-${random_id.bucket_suffix.hex}" + location = "us-east1" + uniform_bucket_level_access = true + storage_class = "STANDARD" + force_destroy = true +} + +# A fixed ip address that is assigned to the load CDN loadbalancer +resource "google_compute_global_address" "ip_address" { + name = "${var.prefix}-ip" +} + +# Backend service for serving static assets from the bucket to the load balancer +resource "google_compute_backend_bucket" "backend" { + name = "${var.prefix}-backend" + description = "Serves static assets for the RFD frontend" + bucket_name = google_storage_bucket.storage_bucket.name + enable_cdn = true + + cdn_policy { + cache_mode = "CACHE_ALL_STATIC" + client_ttl = 30 + default_ttl = 30 + max_ttl = 60 + negative_caching = false + serve_while_stale = 0 + } +} + +# Frontend load balancer +resource "google_compute_url_map" "url_map" { + name = "${var.prefix}-http-lb" + default_service = google_compute_backend_bucket.backend.id +} + +# Route to the load balancer +resource "google_compute_target_https_proxy" "proxy" { + name = "${var.prefix}-https-lb-proxy" + url_map = google_compute_url_map.url_map.id + ssl_certificates = [data.google_compute_ssl_certificate.rfd_static_cert.id] +} + +# Rule to forward all traffic on the external ip address to the RFD static asset backend +resource "google_compute_global_forwarding_rule" "forwarding" { + name = "${var.prefix}-https-lb-forwarding-rule" + ip_protocol = "TCP" + load_balancing_scheme = "EXTERNAL_MANAGED" + port_range = "443" + target = google_compute_target_https_proxy.proxy.id + ip_address = google_compute_global_address.ip_address.id +} diff --git a/.infra/terraform.tfstate b/.infra/terraform.tfstate new file mode 100644 index 0000000..0af5e4a --- /dev/null +++ b/.infra/terraform.tfstate @@ -0,0 +1,277 @@ +{ + "version": 4, + "terraform_version": "1.2.8", + "serial": 16, + "lineage": "84deda26-0791-28cf-991f-b6ddacc4599f", + "outputs": {}, + "resources": [ + { + "mode": "data", + "type": "google_compute_ssl_certificate", + "name": "rfd_static_cert", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "certificate": "", + "certificate_id": 2907959975179360146, + "creation_timestamp": "2022-09-27T09:49:01.234-07:00", + "description": "Static asset serving for RFD frontend", + "id": "projects/websites-326710/global/sslCertificates/rfd-static-cert", + "name": "rfd-static-cert", + "name_prefix": null, + "private_key": null, + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/sslCertificates/rfd-static-cert" + }, + "sensitive_attributes": [] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_backend_bucket", + "name": "backend", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "bucket_name": "rfd-static-assets-f4fa10a22a46223b", + "cdn_policy": [ + { + "bypass_cache_on_request_headers": [], + "cache_key_policy": [], + "cache_mode": "CACHE_ALL_STATIC", + "client_ttl": 30, + "default_ttl": 30, + "max_ttl": 60, + "negative_caching": false, + "negative_caching_policy": [], + "request_coalescing": false, + "serve_while_stale": 0, + "signed_url_cache_max_age_sec": 0 + } + ], + "creation_timestamp": "2022-09-27T08:06:54.133-07:00", + "custom_response_headers": [], + "description": "Serves static assets for the RFD frontend", + "edge_security_policy": "", + "enable_cdn": true, + "id": "projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "name": "rfd-static-assets-backend", + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_global_address", + "name": "ip_address", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "address": "34.160.15.137", + "address_type": "EXTERNAL", + "creation_timestamp": "2022-09-27T08:06:52.591-07:00", + "description": "", + "id": "projects/websites-326710/global/addresses/rfd-static-assets-ip", + "ip_version": "", + "name": "rfd-static-assets-ip", + "network": "", + "prefix_length": 0, + "project": "websites-326710", + "purpose": "", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/addresses/rfd-static-assets-ip", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDB9fQ==" + } + ] + }, + { + "mode": "managed", + "type": "google_compute_global_forwarding_rule", + "name": "forwarding", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "description": "", + "id": "projects/websites-326710/global/forwardingRules/rfd-static-assets-https-lb-forwarding-rule", + "ip_address": "projects/websites-326710/global/addresses/rfd-static-assets-ip", + "ip_protocol": "TCP", + "ip_version": "", + "label_fingerprint": "42WmSpB8rSM=", + "labels": {}, + "load_balancing_scheme": "EXTERNAL_MANAGED", + "metadata_filters": [], + "name": "rfd-static-assets-https-lb-forwarding-rule", + "network": "", + "port_range": "443", + "project": "websites-326710", + "psc_connection_id": "", + "psc_connection_status": "", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/forwardingRules/rfd-static-assets-https-lb-forwarding-rule", + "target": "projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "data.google_compute_ssl_certificate.rfd_static_cert", + "google_compute_backend_bucket.backend", + "google_compute_global_address.ip_address", + "google_compute_target_https_proxy.proxy", + "google_compute_url_map.url_map", + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_target_https_proxy", + "name": "proxy", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "certificate_map": "", + "creation_timestamp": "2022-09-27T08:07:16.516-07:00", + "description": "", + "id": "projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "name": "rfd-static-assets-https-lb-proxy", + "project": "websites-326710", + "proxy_bind": false, + "proxy_id": 2945741823229734731, + "quic_override": "NONE", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "ssl_certificates": [ + "https://www.googleapis.com/compute/v1/projects/websites-326710/global/sslCertificates/rfd-static-cert" + ], + "ssl_policy": "", + "timeouts": null, + "url_map": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb" + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "data.google_compute_ssl_certificate.rfd_static_cert", + "google_compute_url_map.url_map" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_url_map", + "name": "url_map", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "creation_timestamp": "2022-09-27T08:07:05.415-07:00", + "default_route_action": [], + "default_service": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "default_url_redirect": [], + "description": "", + "fingerprint": "ttmsEsEIUTQ=", + "header_action": [], + "host_rule": [], + "id": "projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb", + "map_id": 8342891649728163702, + "name": "rfd-static-assets-http-lb", + "path_matcher": [], + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb", + "test": [], + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "google_compute_backend_bucket.backend", + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_storage_bucket", + "name": "storage_bucket", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "cors": [], + "default_event_based_hold": false, + "encryption": [], + "force_destroy": true, + "id": "rfd-static-assets-f4fa10a22a46223b", + "labels": {}, + "lifecycle_rule": [], + "location": "US-EAST1", + "logging": [], + "name": "rfd-static-assets-f4fa10a22a46223b", + "project": "websites-326710", + "requester_pays": false, + "retention_policy": [], + "self_link": "https://www.googleapis.com/storage/v1/b/rfd-static-assets-f4fa10a22a46223b", + "storage_class": "STANDARD", + "timeouts": null, + "uniform_bucket_level_access": true, + "url": "gs://rfd-static-assets-f4fa10a22a46223b", + "versioning": [], + "website": [] + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoyNDAwMDAwMDAwMDAsInJlYWQiOjI0MDAwMDAwMDAwMCwidXBkYXRlIjoyNDAwMDAwMDAwMDB9fQ==", + "dependencies": ["random_id.bucket_suffix"] + } + ] + }, + { + "mode": "managed", + "type": "random_id", + "name": "bucket_suffix", + "provider": "provider[\"registry.terraform.io/hashicorp/random\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "b64_std": "9PoQoipGIjs=", + "b64_url": "9PoQoipGIjs", + "byte_length": 8, + "dec": "17652439978112066107", + "hex": "f4fa10a22a46223b", + "id": "9PoQoipGIjs", + "keepers": null, + "prefix": null + }, + "sensitive_attributes": [] + } + ] + } + ] +} diff --git a/.infra/terraform.tfstate.backup b/.infra/terraform.tfstate.backup new file mode 100644 index 0000000..4e45e7e --- /dev/null +++ b/.infra/terraform.tfstate.backup @@ -0,0 +1,277 @@ +{ + "version": 4, + "terraform_version": "1.2.8", + "serial": 14, + "lineage": "84deda26-0791-28cf-991f-b6ddacc4599f", + "outputs": {}, + "resources": [ + { + "mode": "data", + "type": "google_compute_ssl_certificate", + "name": "rfd_static_cert", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "certificate": "", + "certificate_id": 8361335420413188081, + "creation_timestamp": "2022-09-27T09:47:26.817-07:00", + "description": "Static asset serving for RFD frontend", + "id": "projects/websites-326710/global/sslCertificates/rfd-static-cert-2", + "name": "rfd-static-cert-2", + "name_prefix": null, + "private_key": null, + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/sslCertificates/rfd-static-cert-2" + }, + "sensitive_attributes": [] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_backend_bucket", + "name": "backend", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "bucket_name": "rfd-static-assets-f4fa10a22a46223b", + "cdn_policy": [ + { + "bypass_cache_on_request_headers": [], + "cache_key_policy": [], + "cache_mode": "CACHE_ALL_STATIC", + "client_ttl": 30, + "default_ttl": 30, + "max_ttl": 60, + "negative_caching": false, + "negative_caching_policy": [], + "request_coalescing": false, + "serve_while_stale": 0, + "signed_url_cache_max_age_sec": 0 + } + ], + "creation_timestamp": "2022-09-27T08:06:54.133-07:00", + "custom_response_headers": [], + "description": "Serves static assets for the RFD frontend", + "edge_security_policy": "", + "enable_cdn": true, + "id": "projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "name": "rfd-static-assets-backend", + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_global_address", + "name": "ip_address", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "address": "34.160.15.137", + "address_type": "EXTERNAL", + "creation_timestamp": "2022-09-27T08:06:52.591-07:00", + "description": "", + "id": "projects/websites-326710/global/addresses/rfd-static-assets-ip", + "ip_version": "", + "name": "rfd-static-assets-ip", + "network": "", + "prefix_length": 0, + "project": "websites-326710", + "purpose": "", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/addresses/rfd-static-assets-ip", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDB9fQ==" + } + ] + }, + { + "mode": "managed", + "type": "google_compute_global_forwarding_rule", + "name": "forwarding", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "description": "", + "id": "projects/websites-326710/global/forwardingRules/rfd-static-assets-https-lb-forwarding-rule", + "ip_address": "projects/websites-326710/global/addresses/rfd-static-assets-ip", + "ip_protocol": "TCP", + "ip_version": "", + "label_fingerprint": "42WmSpB8rSM=", + "labels": {}, + "load_balancing_scheme": "EXTERNAL_MANAGED", + "metadata_filters": [], + "name": "rfd-static-assets-https-lb-forwarding-rule", + "network": "", + "port_range": "443", + "project": "websites-326710", + "psc_connection_id": "", + "psc_connection_status": "", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/forwardingRules/rfd-static-assets-https-lb-forwarding-rule", + "target": "projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "data.google_compute_ssl_certificate.rfd_static_cert", + "google_compute_backend_bucket.backend", + "google_compute_global_address.ip_address", + "google_compute_target_https_proxy.proxy", + "google_compute_url_map.url_map", + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_target_https_proxy", + "name": "proxy", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "certificate_map": "", + "creation_timestamp": "2022-09-27T08:07:16.516-07:00", + "description": "", + "id": "projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "name": "rfd-static-assets-https-lb-proxy", + "project": "websites-326710", + "proxy_bind": false, + "proxy_id": 2945741823229734731, + "quic_override": "NONE", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/targetHttpsProxies/rfd-static-assets-https-lb-proxy", + "ssl_certificates": [ + "https://www.googleapis.com/compute/v1/projects/websites-326710/global/sslCertificates/rfd-static-cert-2" + ], + "ssl_policy": "", + "timeouts": null, + "url_map": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb" + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "data.google_compute_ssl_certificate.rfd_static_cert", + "google_compute_url_map.url_map" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_compute_url_map", + "name": "url_map", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "creation_timestamp": "2022-09-27T08:07:05.415-07:00", + "default_route_action": [], + "default_service": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/backendBuckets/rfd-static-assets-backend", + "default_url_redirect": [], + "description": "", + "fingerprint": "ttmsEsEIUTQ=", + "header_action": [], + "host_rule": [], + "id": "projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb", + "map_id": 8342891649728163702, + "name": "rfd-static-assets-http-lb", + "path_matcher": [], + "project": "websites-326710", + "self_link": "https://www.googleapis.com/compute/v1/projects/websites-326710/global/urlMaps/rfd-static-assets-http-lb", + "test": [], + "timeouts": null + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", + "dependencies": [ + "google_compute_backend_bucket.backend", + "google_storage_bucket.storage_bucket", + "random_id.bucket_suffix" + ] + } + ] + }, + { + "mode": "managed", + "type": "google_storage_bucket", + "name": "storage_bucket", + "provider": "provider[\"registry.terraform.io/hashicorp/google\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "cors": [], + "default_event_based_hold": false, + "encryption": [], + "force_destroy": true, + "id": "rfd-static-assets-f4fa10a22a46223b", + "labels": {}, + "lifecycle_rule": [], + "location": "US-EAST1", + "logging": [], + "name": "rfd-static-assets-f4fa10a22a46223b", + "project": "websites-326710", + "requester_pays": false, + "retention_policy": [], + "self_link": "https://www.googleapis.com/storage/v1/b/rfd-static-assets-f4fa10a22a46223b", + "storage_class": "STANDARD", + "timeouts": null, + "uniform_bucket_level_access": true, + "url": "gs://rfd-static-assets-f4fa10a22a46223b", + "versioning": [], + "website": [] + }, + "sensitive_attributes": [], + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoyNDAwMDAwMDAwMDAsInJlYWQiOjI0MDAwMDAwMDAwMCwidXBkYXRlIjoyNDAwMDAwMDAwMDB9fQ==", + "dependencies": ["random_id.bucket_suffix"] + } + ] + }, + { + "mode": "managed", + "type": "random_id", + "name": "bucket_suffix", + "provider": "provider[\"registry.terraform.io/hashicorp/random\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "b64_std": "9PoQoipGIjs=", + "b64_url": "9PoQoipGIjs", + "byte_length": 8, + "dec": "17652439978112066107", + "hex": "f4fa10a22a46223b", + "id": "9PoQoipGIjs", + "keepers": null, + "prefix": null + }, + "sensitive_attributes": [] + } + ] + } + ] +} diff --git a/.infra/versions.tf b/.infra/versions.tf new file mode 100644 index 0000000..9e45a9e --- /dev/null +++ b/.infra/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } + required_version = ">= 1.2.8" +} diff --git a/.licenserc.yaml b/.licenserc.yaml new file mode 100644 index 0000000..d66d206 --- /dev/null +++ b/.licenserc.yaml @@ -0,0 +1,16 @@ +header: + # default is 80, need to make it slightly longer for a long shebang + license-location-threshold: 100 + license: + spdx-id: MPL-2.0 + content: | + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, you can obtain one at https://mozilla.org/MPL/2.0/. + + Copyright Oxide Computer Company + + paths: + - '**/*.{ts,tsx,css,html,js,sh}' + + comment: on-failure diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..874a571 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 92, + "proseWrap": "always", + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "plugins": ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], + "importOrder": ["", "", "^~/(.*)$", "", "^[./]"], + "importOrderTypeScriptVersion": "5.2.2" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..2010f02 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# RFD Site + +## Table of Contents + +- [Introduction](#rfd-site) +- [Technology](#the-technology) +- [Contributing](#contributing) + - [Setup](#setup) +- [Running](#running) + - [Running Locally](#running-locally) + - [Write RFDs Locally](#write-rfds-locally) +- [License](#license) + +## Introduction + +At Oxide, RFDs (Requests for Discussion) play a crucial role in driving our architectural +and design decisions. They document the processes, APIs, and tools that we use. To learn +more about the RFD process, you can read +[RFD 1: Requests for Discussion](https://rfd.shared.oxide.computer/rfd/0001). + +This repo represents the web frontend for browsing, searching, and reading RFDs, not to be +confused with [`oxidecomputer/rfd`](https://github.com/oxidecomputer/rfd), a private repo +that houses RFD content and discussion, or +[`oxidecomputer/rfd-api`](https://github.com/oxidecomputer/rfd-api), the backend that serves +RFD content to this site and gives us granular per-user control over RFD access. You can +read more about this site and how we use it in our blog post +[A Tool for Discussion](https://oxide.computer/blog/a-tool-for-discussion). + +## Technology + +The site is built with [Remix](https://remix.run/), a full stack React web framework. +[rfd-api](https://github.com/oxidecomputer/rfd-api) collects the RFDs from +`oxidecomputer/rfd` stores it in a database, and serves it through an HTTP API, which this +site uses. RFD discussions come from an associated pull request on GitHub. These are linked +to from the document and displayed inline alongside the text. + +Documents are rendered with +[react-asciidoc](https://github.com/oxidecomputer/react-asciidoc), a work-in-progress React +AsciiDoc renderer we've created, built on top of +[`asciidoctor.js`](https://github.com/asciidoctor/asciidoctor.js). + +## Deploying + +Our site is hosted on Vercel and this repo uses the Vercel adapter, but Remix can be +deployed to [any JS runtime](https://remix.run/docs/en/main/discussion/runtimes). + +## Contributing + +This repo is public because others are interested in the RFD process and the tooling we've +built around it. In its present state, it's the code we're using on our +[deployed site](https://rfd.shared.oxide.computer/) and is tightly coupled to us and our +[design system](https://github.com/oxidecomputer/design-system). We're open to PRs that +improve this site, especially if they make the repo easier for others to use and contribute +to. However, we are a small company, and the primary goal of this repo is as an internal +tool for Oxide, so we can't guarantee that PRs will be integrated. + +## Running + +### Setup + +`npm` v7 or higher is recommended due to +[`lockfileVersion: 2`](https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json#lockfileversion) +in `package-lock.json`. + +```sh +npm install +``` + +### Running Locally + +```sh +npm run dev +``` + +and go to [http://localhost:3000](http://localhost:3000). The site will live-reload on file +changes. The site should work with local RFDs (without search) without having to set any env +vars. See below on how to set up local RFD preview. + +### Write RFDs Locally + +To preview an RFD you're working on in the site, use the `LOCAL_RFD_REPO` env var to tell +the site to pull content from your local clone of the `rfd` repo instead of the API. No +other env vars (such as the ones that let you talk to CIO) are required. For example: + +```sh +LOCAL_RFD_REPO=~/oxide/rfd npm run dev +``` + +Then go to `localhost:3000/rfd/0123` as normal. When you edit the file in the other repo, +the page will reload automatically. The index also works in local mode: it lists all RFDs it +can see locally. + +Note that this does not pull RFDs from all branches like the production site does. It simply +reads files from the specified directory, so it will only have access to files on the +current branch. Missing RFDs will 404. If you are working on two RFDs and they're on +different branches, you cannot preview both at the same time unless you make a temporary +combined branch that contains both. + +## License + +Unless otherwise noted, all components are licensed under the +[Mozilla Public License Version 2.0](LICENSE). diff --git a/app/components/AsciidocBlocks/Document.tsx b/app/components/AsciidocBlocks/Document.tsx new file mode 100644 index 0000000..4b5ab4e --- /dev/null +++ b/app/components/AsciidocBlocks/Document.tsx @@ -0,0 +1,177 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Content, type AdocTypes } from '@oxide/react-asciidoc' +import { Link, useLocation } from '@remix-run/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import tunnel from 'tunnel-rat' + +import { isTruthy } from '~/utils/isTruthy' + +import Container from '../Container' +import { GotoIcon } from '../CustomIcons' +import { + DesktopOutline, + generateTableOfContents, + SmallScreenOutline, + useActiveSectionTracking, + useIntersectionObserver, +} from '../TableOfContents' + +export const ui = tunnel() + +const CustomDocument = ({ document }: { document: AdocTypes.Document }) => { + const [titleEl, setTitleEl] = useState(null) + const bodyRef = useRef(null) + const [activeItem, setActiveItem] = useState('') + + const toc = useMemo(() => generateTableOfContents(document.getSections()), [document]) + + const { pathname, hash } = useLocation() + + const onActiveElementUpdate = useCallback( + (el: Element | null) => { + setActiveItem(el?.id || '') + // history.replaceState({}, '', el ? `#${el.id}` : window.location.pathname) + }, + [setActiveItem], + ) + + // Connect handlers for managing the active (visible section) of the page + const { setSections } = useActiveSectionTracking([], onActiveElementUpdate) + + // Add handler for resetting back to the empty state when the top of the page is reached. + useIntersectionObserver( + useMemo(() => [titleEl].filter(isTruthy), [titleEl]), + useCallback( + (entries) => entries[0].isIntersecting && onActiveElementUpdate(null), + [onActiveElementUpdate], + ), + useMemo(() => ({ rootMargin: '0px 0px -80% 0px' }), []), + ) + + useEffect(() => { + let headings = toc + .filter((item) => item.level <= 2) + .map((item) => { + // wrap in try catch because sometimes heading IDs don't make valid + // selectors, so rather than blowing up, we just ignore them + try { + return bodyRef.current?.querySelector(`#${item.id}`) + } catch (e) { + return null + } + }) + .filter(isTruthy) + + setSections(headings) + }, [toc, setSections]) + + const blocks = document.getBlocks() + const title = (document.getDocumentTitle() || '').toString() + + const [footnotes, setFootnotes] = useState() + + useMemo(() => { + if (blocks || blocks[0]) { + setFootnotes(document.getFootnotes()) + } + }, [document, blocks]) + + const Footnotes = () => { + if (!footnotes) return null + + if ( + footnotes.length > 0 && + blocks && + !blocks[0].getDocument().hasAttribute('nofootnotes') + ) { + return ( +
+ +
+ Footnotes +
+ +
    + {footnotes.map((footnote) => ( +
  • +
    + {footnote.getIndex()} +
    +
    +

    {' '} + + + + View + + +

    +
  • + ))} +
+
+
+ ) + } else { + return null + } + } + + return ( + <> + + {/* + Blank element at the top of the page to use to reset + the selected section in the table of contents + */} +
+
+
+ +
+
+ + +
+
+ + + + + ) +} + +export default CustomDocument diff --git a/app/components/AsciidocBlocks/Image.tsx b/app/components/AsciidocBlocks/Image.tsx new file mode 100644 index 0000000..85fa341 --- /dev/null +++ b/app/components/AsciidocBlocks/Image.tsx @@ -0,0 +1,105 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import * as Ariakit from '@ariakit/react' +import { CaptionedTitle, type AdocTypes } from '@oxide/react-asciidoc' +import { useState } from 'react' + +function nodeIsInline(node: AdocTypes.Block | AdocTypes.Inline): node is AdocTypes.Inline { + return node.isInline() +} + +const Image = ({ + node, + hasLightbox = true, +}: { + node: AdocTypes.Block | AdocTypes.Inline + hasLightbox?: boolean +}) => { + const documentAttrs = node.getDocument().getAttributes() + + let target = '' + if (nodeIsInline(node)) { + target = node.getTarget() || '' // Getting target on inline nodes + } else { + target = node.getAttribute('target') // Getting target on block nodes + } + + let uri = node.getImageUri(target) + let url = '' + + const [lightboxOpen, setLightboxOpen] = useState(false) + + url = `/rfd/image/${documentAttrs.rfdnumber}/${uri}` + + let img = ( + {node.getAttribute('alt')} + ) + + if (node.hasAttribute('link')) { + img = ( + + {img} + + ) + } + + if (nodeIsInline(node)) { + return ( + + {img} + + ) + } else { + return ( + <> +
setLightboxOpen(true)} + > +
{img}
+ +
+ {hasLightbox && ( + setLightboxOpen(false)} + className="fixed [&_img]:mx-auto" + backdrop={
} + > + + {node.getAttribute('alt')} + + + )} + + ) + } +} + +export default Image diff --git a/app/components/AsciidocBlocks/Listing.tsx b/app/components/AsciidocBlocks/Listing.tsx new file mode 100644 index 0000000..d8fe7c4 --- /dev/null +++ b/app/components/AsciidocBlocks/Listing.tsx @@ -0,0 +1,93 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + CaptionedTitle, + getContent, + getLineNumber, + type AdocTypes, +} from '@oxide/react-asciidoc' +import cn from 'classnames' +import hljs from 'highlight.js' +import { decode } from 'html-entities' + +import Mermaid from './Mermaid' + +// Custom highlight.js language definition to support TLA+ +// Reference: https://github.com/highlightjs/highlight.js/pull/1658 +hljs.registerLanguage('tla', function (hljs) { + return { + keywords: { + keyword: + 'ASSUME ASSUMPTION AXIOM BOOLEAN CASE CONSTANT CONSTANTS ELSE EXCEPT EXTENDS FALSE ' + + 'IF IN INSTANCE LET LOCAL MODULE OTHER STRING THEN THEOREM LEMMA PROPOSITION COROLLARY ' + + 'TRUE VARIABLE VARIABLES WITH CHOOSE ENABLED UNCHANGED SUBSET UNION DOMAIN BY OBVIOUS ' + + 'HAVE QED TAKE DEF HIDE RECURSIVE USE DEFINE PROOF WITNESS PICK DEFS PROVE SUFFICES ' + + 'NEW LAMBDA STATE ACTION TEMPORAL ONLY OMITTED ', + }, + contains: [ + hljs.QUOTE_STRING_MODE, + hljs.COMMENT('\\(\\*', '\\*\\)'), + hljs.COMMENT('\\\\\\*', '$'), + hljs.C_NUMBER_MODE, + { begin: /\/\\/ }, // relevance booster + ], + } +}) + +// Inspired by the HTML5 listing convert function +// https://github.com/asciidoctor/asciidoctor/blob/82c5044d1ae5a45a83a8c82d26d5b5b86fcbc179/lib/asciidoctor/converter/html5.rb#L653-L678 +const Listing = ({ node }: { node: AdocTypes.Block }) => { + const document = node.getDocument() + const attrs = node.getAttributes() + const nowrap = node.isOption('nowrap') || !document.hasAttribute('prewrap') + const content = getContent(node) + const decodedContent = decode(content) || content // unescape the html entities + + // Listing blocks of style `source` are source code, should have their syntax + // highlighted (where we have language support) and be inside both a `pre` and `code` tag + if (node.getStyle() === 'source') { + const lang = attrs.language + + return ( +
+ +
+
+            {lang && lang === 'mermaid' ? (
+              
+            ) : (
+              
+            )}
+          
+
+
+ ) + } else { + // Regular listing blocks are wrapped only in a `pre` tag + return ( +
+ +
+
{node.getSource()}
+
+
+ ) + } +} + +export default Listing diff --git a/app/components/AsciidocBlocks/Mermaid.tsx b/app/components/AsciidocBlocks/Mermaid.tsx new file mode 100644 index 0000000..2f203d5 --- /dev/null +++ b/app/components/AsciidocBlocks/Mermaid.tsx @@ -0,0 +1,62 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import mermaid from 'mermaid' +import { useEffect, useId, useState } from 'react' + +mermaid.initialize({ + startOnLoad: false, + // @ts-ignore The types are wrong here. Base is available and is what's required for theming + theme: 'base', + themeVariables: { + darkMode: true, + background: '#080F11', + primaryColor: '#1C2225', + primaryTextColor: '#E7E7E8', + primaryBorderColor: '#238A5E', + fontFamily: + 'SuisseIntl, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif', + lineColor: '#7E8385', + }, + flowchart: { + curve: 'cardinal', + }, +}) + +const Mermaid = ({ content }: { content: string }) => { + const [html, setHtml] = useState('') + const [showSource, setShowSource] = useState(false) + const id = `mermaid-diagram-${useId().replace(/:/g, '_')}` + + useEffect(() => { + const renderMermaid = async () => { + const { svg } = await mermaid.render(id, content) + setHtml(svg) + } + renderMermaid() + }, [id, content, setHtml]) + + return ( + <> + + {html && !showSource ? ( + + ) : ( + {content} + )} + + ) +} + +export default Mermaid diff --git a/app/components/AsciidocBlocks/Section.tsx b/app/components/AsciidocBlocks/Section.tsx new file mode 100644 index 0000000..47f0339 --- /dev/null +++ b/app/components/AsciidocBlocks/Section.tsx @@ -0,0 +1,87 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { Section as SectionType } from '@asciidoctor/core' +import { Content, getRole, parse } from '@oxide/react-asciidoc' +import cn from 'classnames' +import { createElement } from 'react' + +import Icon from '../Icon' + +// We need to remove anchors from the section title (and table of contents) because having +// an anchor within an anchor causes a client/server render mismatch +export const stripAnchors = (str: string) => str.replace(/]*>(.*?)<\/a>/gi, '$1') + +const Section = ({ node }: { node: SectionType }) => { + const docAttrs = node.getDocument().getAttributes() + const level = node.getLevel() + let title: JSX.Element | string = '' + + let sectNum = node.getSectionNumeral() + sectNum = sectNum === '.' ? '' : sectNum + + const sectNumLevels = docAttrs['sectnumlevels'] ? parseInt(docAttrs['sectnumlevels']) : 3 + + if (node.getCaption()) { + title = node.getCaptionedTitle() + } else if (node.isNumbered() && level <= sectNumLevels) { + if (level < 2 && node.getDocument().getDoctype() === 'book') { + const sectionName = node.getSectionName() + if (sectionName === 'chapter') { + const signifier = docAttrs['chapter-signifier'] + title = `${signifier || ''} ${sectNum} ${node.getTitle()}` + } else if (sectionName === 'part') { + const signifier = docAttrs['part-signifier'] + title = `${signifier || ''} ${sectNum} ${node.getTitle()}` + } else { + title = node.getTitle() || '' + } + } else { + title = node.getTitle() || '' + } + } else { + title = node.getTitle() || '' + } + + title = ( + <> + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/anchor-has-content */} + + {parse(stripAnchors(title))} + + + + ) + + if (level === 0) { + return ( + <> +

+ {title} +

+ + + ) + } else { + return ( +
+ {createElement(`h${level + 1}`, { 'data-sectnum': sectNum }, title)} +
+ +
+
+ ) + } +} + +export default Section diff --git a/app/components/AsciidocBlocks/index.ts b/app/components/AsciidocBlocks/index.ts new file mode 100644 index 0000000..22d326d --- /dev/null +++ b/app/components/AsciidocBlocks/index.ts @@ -0,0 +1,72 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { AsciiDocBlocks } from '@oxide/design-system/components/dist' +import { getText, type AdocTypes, type Options } from '@oxide/react-asciidoc' + +import CustomDocument, { ui } from './Document' +import Image from './Image' +import Listing from './Listing' +import Section from './Section' + +export const opts: Options = { + overrides: { + admonition: AsciiDocBlocks.Admonition, + table: AsciiDocBlocks.Table, + image: Image, + listing: Listing, + section: Section, + }, + customDocument: CustomDocument, +} + +export const renderWithBreaks = (text: string): string => { + return text.replaceAll(/(?') +} + +// prettier-ignore +const QUOTE_TAGS: {[key: string]: [string, string, boolean?]} = { + "monospaced": ['', '', true], + "emphasis": ['', '', true], + "strong": ['', '', true], + "double": ['“', '”'], + "single": ['‘', '’'], + "mark": ['', '', true], + "superscript": ['', '', true], + "subscript": ['', '', true], + "unquoted": ['', '', true], + "asciimath": ['\\$', '\\$'], + "latexmath": ['\\(', '\\)'], +} + +const chop = (str: string) => str.substring(0, str.length - 1) + +const convertInlineQuoted = (node: AdocTypes.Inline) => { + const type = node.getType() + const quoteTag = QUOTE_TAGS[type] + const [open, close, tag] = quoteTag || ['', ''] + + let text = getText(node) + + // Add for line breaks with long paths + // Ignores a / if there's a space before it + if (type === 'monospaced') { + text = renderWithBreaks(text) + } + + const idAttr = node.getId() ? `id="${node.getId()}"` : '' + const classAttr = node.getRole() ? `class="${node.getRole()}"` : '' + + if (tag) { + return `${chop(open)} ${idAttr} ${classAttr}>${text}${close}` + } else { + return `${open}${text}${close}` + } +} + +export { ui, convertInlineQuoted } diff --git a/app/components/Container.tsx b/app/components/Container.tsx new file mode 100644 index 0000000..06ac8c1 --- /dev/null +++ b/app/components/Container.tsx @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import cn from 'classnames/dedupe' + +const Container = ({ + className, + wrapperClassName, + children, + isGrid, +}: { + className?: string + wrapperClassName?: string + children: React.ReactNode + isGrid?: boolean +}) => ( +
+
+ {children} +
+
+) + +export default Container diff --git a/app/components/CustomIcons.tsx b/app/components/CustomIcons.tsx new file mode 100644 index 0000000..f12b453 --- /dev/null +++ b/app/components/CustomIcons.tsx @@ -0,0 +1,57 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +export const SortArrowTop = ({ className }: { className?: string }) => ( + + + +) + +export const SortArrowBottom = ({ className }: { className?: string }) => ( + + + +) + +export const GotoIcon = ({ className }: { className?: string }) => ( + + + +) diff --git a/app/components/Dropdown.tsx b/app/components/Dropdown.tsx new file mode 100644 index 0000000..75108d6 --- /dev/null +++ b/app/components/Dropdown.tsx @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import * as Dropdown from '@radix-ui/react-dropdown-menu' +import cn from 'classnames' +import type { ReactNode } from 'react' + +import Icon from '~/components/Icon' + +export const dropdownOuterStyles = + 'menu-item relative text-sans-md text-secondary border-b border-secondary cursor-pointer' + +export const dropdownInnerStyles = `focus:outline-0 focus:bg-hover px-3 py-2 pr-6` + +export const DropdownItem = ({ + children, + classNames, + onSelect, +}: { + children: ReactNode | string + classNames?: string + onSelect?: () => void +}) => ( + + {children} + +) + +export const DropdownSubTrigger = ({ + children, + classNames, +}: { + children: JSX.Element | string + classNames?: string +}) => ( + + {children} + + +) + +export const DropdownLink = ({ + children, + classNames, + internal = false, + to, + disabled = false, +}: { + children: React.ReactNode + classNames?: string + internal?: boolean + to: string + disabled?: boolean +}) => ( + + + {children} + + +) + +export const DropdownMenu = ({ + children, + classNames, + align = 'end', +}: { + children: React.ReactNode + classNames?: string + align?: 'end' | 'start' | 'center' | undefined +}) => ( + + *:last-child]:border-b-0', + classNames, + )} + align={align} + > + {children} + + +) + +export const DropdownSubMenu = ({ + children, + classNames, +}: { + children: JSX.Element[] + classNames?: string +}) => ( + + *:last-child]:border-b-0', + classNames, + )} + > + {children} + + +) diff --git a/app/components/Header.tsx b/app/components/Header.tsx new file mode 100644 index 0000000..f1a726b --- /dev/null +++ b/app/components/Header.tsx @@ -0,0 +1,118 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { buttonStyle } from '@oxide/design-system' +import * as Dropdown from '@radix-ui/react-dropdown-menu' +import { Link, useFetcher } from '@remix-run/react' +import { useCallback, useState } from 'react' + +import Icon from '~/components/Icon' +import NewRfdButton from '~/components/NewRfdButton' +import { useKey } from '~/hooks/use-key' +import { useRootLoaderData } from '~/root' +import type { RfdItem, RfdListItem } from '~/services/rfd.server' + +import { DropdownItem, DropdownMenu } from './Dropdown' +import { PublicBanner } from './PublicBanner' +import Search from './Search' +import SelectRfdCombobox from './SelectRfdCombobox' + +export type SmallRfdItems = { + [key: number]: RfdListItem +} + +export default function Header({ currentRfd }: { currentRfd?: RfdItem }) { + const { user, rfds, isLocalMode, inlineComments } = useRootLoaderData() + + const fetcher = useFetcher() + + const toggleTheme = () => { + fetcher.submit({}, { method: 'post', action: '/user/toggle-theme' }) + } + + const toggleInlineComments = () => { + fetcher.submit({}, { method: 'post', action: '/user/toggle-inline-comments' }) + } + + const logout = () => { + fetcher.submit({}, { method: 'post', action: '/logout' }) + } + + const returnTo = currentRfd ? `/rfd/${currentRfd.number_string}` : '/' + + const [open, setOpen] = useState(false) + + // memoized to avoid render churn in useKey + const toggleSearchMenu = useCallback(() => { + setOpen(!open) + return false // Returning false prevents default behaviour in Firefox + }, [open]) + + useKey('mod+k', toggleSearchMenu) + + return ( +
+ {!user && } +
+
+ + + + +
+ +
+ + setOpen(false)} /> + + + {user ? ( + + + + + {user.displayName || user.email} + + + + + + Toggle theme + + {inlineComments ? 'Hide' : 'Show'} inline comments + + {isLocalMode ? ( + <> + ) : ( + Log out + )} + + + ) : ( + + Sign in + + )} +
+
+
+ ) +} diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx new file mode 100644 index 0000000..32b02c2 --- /dev/null +++ b/app/components/Icon.tsx @@ -0,0 +1,28 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { type Icon as IconType } from '@oxide/design-system/icons' + +import sprite from '../../node_modules/@oxide/design-system/icons/sprite.svg' + +type IconProps = IconType & { + className?: string + height?: number +} + +const Icon = ({ name, size, ...props }: IconProps) => { + const id = `${name}-${size}` + + return ( + + + + ) +} + +export default Icon diff --git a/app/components/LoadingBar.tsx b/app/components/LoadingBar.tsx new file mode 100644 index 0000000..e8f57ed --- /dev/null +++ b/app/components/LoadingBar.tsx @@ -0,0 +1,108 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useNavigation } from '@remix-run/react' +import { useEffect, useRef } from 'react' + +const LOADING_BAR_DELAY_MS = 20 + +/** + * Loading bar for top-level navigations. When a nav first starts, the bar zooms + * from 0 to A quickly and then more slowly grows from A to B. The idea is that + * the actual fetching should almost always complete while the bar is between A + * and B. The animation from 0 to A to B is represented by the `loading` label. + * Then once we're done fetching, we switch to the `done` animation from B to + * 100. + * + * **Important:** we only do any of this if the navigation takes longer than + * `LOADING_BAR_DELAY_MS`. This prevents us from showing the loading bar on navs + * that are instantaneous, like opening a create form. Sometimes normal page + * navs are also instantaneous due to caching. + * + * ``` + * ├──────────┼──────────┼──────────┤ + * 0 A B 100 + * + * └─────────┰──────────┘ └────┰────┘ + * loading done + * ``` + */ +function LoadingBar() { + const navigation = useNavigation() + + // use a ref because there's no need to bring React state into this + const barRef = useRef(null) + + // only used for checking the loading state from inside the timeout callback + const loadingRef = useRef(false) + loadingRef.current = navigation.state === 'loading' + + useEffect(() => { + const loading = navigation.state === 'loading' + if (barRef.current) { + if (loading) { + // instead of adding the `loading` class right when loading starts, set + // a LOADING_BAR_DELAY_MS timeout that starts the animation, but ONLY if + // we are still loading when the callback runs. If the loaders in a + // particular nav finish immediately, the value of `loadingRef.current` + // will be back to `false` by the time the callback runs, skipping the + // animation sequence entirely. + const timeout = setTimeout(() => { + if (loadingRef.current) { + // Remove class and force reflow. Without this, the animation does + // not restart from the beginning if we nav again while already + // loading. https://gist.github.com/paulirish/5d52fb081b3570c81e3a + // + // It's important that this happen inside the timeout and inside the + // condition for the case where we're doing an instant nav while a + // nav animation is already running. If we did this outside the + // timeout callback or even inside the callback but outside the + // condition, we'd immediately kill an in-progress loading animation + // that was about to finish on its own anyway. + barRef.current?.classList.remove('loading', 'done') + + // Kick off the animation + barRef.current?.classList.add('loading') + } + }, LOADING_BAR_DELAY_MS) + + // Clean up the timeout if we get another render in the meantime. This + // doesn't seem to affect behavior but it's the Correct thing to do. + return () => clearTimeout(timeout) + } else if (barRef.current.classList.contains('loading')) { + // Needs the if condition because if loading is false and we *don't* + // have the `loading` animation running, we're on initial pageload and + // we don't want to run the done animation. This is also necessary for + // the case where we want to skip the animation entirely because the + // loaders finished very quickly: when we get here, the callback that + // sets the loading class will not have run yet, so we will not apply + // the done class, which is correct because we don't want to run the + // `done` animation if the `loading` animation hasn't happened. + + barRef.current.classList.replace('loading', 'done') + + // We don't need to remove `done` when it's over done because the final + // state has opacity 0, and whenever a new animation starts, we remove + // `done` to start fresh. + } + } + // It is essential that we have `navigation` here as a dep rather than + // calculating `loading` outside and using that as the dep. If we do the + // latter, this effect does not run when a new nav happens while we're + // already loading, because the value of `loading` does not change in that + // case. The value of `navigation` does change on each new nav. + }, [navigation]) + + return ( +
+
+
+ ) +} + +export default LoadingBar diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx new file mode 100644 index 0000000..236e51f --- /dev/null +++ b/app/components/Modal.tsx @@ -0,0 +1,42 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Dialog, DialogDismiss, type DialogStore } from '@ariakit/react' + +import Icon from '~/components/Icon' + +const Modal = ({ + dialogStore, + title, + children, +}: { + dialogStore: DialogStore + title: string + children: React.ReactElement +}) => { + return ( + <> + } + > +
+
{title}
+ + + +
+ +
{children}
+
+ + ) +} + +export default Modal diff --git a/app/components/NewRfdButton.tsx b/app/components/NewRfdButton.tsx new file mode 100644 index 0000000..9cf908f --- /dev/null +++ b/app/components/NewRfdButton.tsx @@ -0,0 +1,65 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useDialogStore } from '@ariakit/react' + +import Icon from '~/components/Icon' +import { useRootLoaderData } from '~/root' + +import Modal from './Modal' + +const NewRfdButton = () => { + const dialog = useDialogStore() + const newRfdNumber = useRootLoaderData().newRfdNumber + + return ( + <> + + + + <> +

+ There is a prototype script in the rfd{' '} + + repository + + ,{' '} + + scripts/new.sh + + , that will create a new RFD when used like the code below. +

+ +

+ {newRfdNumber + ? 'The snippet below automatically updates to ensure the new RFD number is correct.' + : 'Replace the number below with the next free number'} +

+
+            
+              $
+              scripts/new.sh{' '}
+              {newRfdNumber ? newRfdNumber.toString().padStart(4, '0') : '0042'} "My title
+              here"
+            
+          
+ +
+ + ) +} + +export default NewRfdButton diff --git a/app/components/NotFound.tsx b/app/components/NotFound.tsx new file mode 100644 index 0000000..5989e56 --- /dev/null +++ b/app/components/NotFound.tsx @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import Icon from '~/components/Icon' +import { Layout } from '~/root' + +export default function NotFound() { + return ( + +
+
+ +
+
+
+
+ +
+ +
+

Page not found

+

+ The page you are looking for doesn’t exist or you may not have access to it. +

+
+
+
+
+ + ) +} diff --git a/app/components/PublicBanner.tsx b/app/components/PublicBanner.tsx new file mode 100644 index 0000000..1f28477 --- /dev/null +++ b/app/components/PublicBanner.tsx @@ -0,0 +1,85 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useDialogStore } from '@ariakit/react' +import { Link } from '@remix-run/react' +import { type ReactNode } from 'react' + +import Icon from '~/components/Icon' + +import Modal from './Modal' + +function ExternalLink({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + ) +} + +export function PublicBanner() { + const dialog = useDialogStore() + + return ( + <> + {/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */} + + + +
+

+ These are the publicly available{' '} + dialog.setOpen(false)} + > + RFDs + {' '} + from Oxide. Those + with access should{' '} + + sign in + {' '} + to view the full directory of RFDs. +

+

+ We use RFDs both to discuss rough ideas and as a permanent repository for more + established ones. You can read more about the{' '} + + tooling around discussions + + . +

+

+ If you're interested in the way we work, and would like to see the process from + the inside, check out our{' '} + + open positions + + . +

+
+
+ + ) +} diff --git a/app/components/Search.tsx b/app/components/Search.tsx new file mode 100644 index 0000000..465a211 --- /dev/null +++ b/app/components/Search.tsx @@ -0,0 +1,522 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Dialog, DialogDismiss } from '@ariakit/react' +import { + instantMeiliSearch, + type AlgoliaMultipleQueriesQuery, + type InstantMeiliSearchInstance, +} from '@meilisearch/instant-meilisearch' +import { Link } from '@remix-run/react' +import { useQuery } from '@tanstack/react-query' +import cn from 'classnames' +import dayjs from 'dayjs' +import type { BaseHit, Hit } from 'instantsearch.js' +import { createRef, Fragment, useEffect, useRef, useState } from 'react' +import { + Configure, + Highlight, + InstantSearch, + Snippet, + useHits, + useSearchBox, +} from 'react-instantsearch-hooks-web' +import { useNavigate } from 'react-router-dom' + +import Icon from '~/components/Icon' +import StatusBadge from '~/components/StatusBadge' +import { useSteppedScroll } from '~/hooks/use-stepped-scroll' +import type { RfdItem } from '~/services/rfd.server' + +const Search = ({ open, onClose }: { open: boolean; onClose: () => void }) => { + let searchClient = useRef() + + useEffect(() => { + const client: InstantMeiliSearchInstance = instantMeiliSearch( + 'https://search.rfd.shared.oxide.computer', + ) + + // Overriding search function to implement our custom search backend. We provide a search route + // that proxies out to the RFD API search endpoint. This route accepts slightly different + // argument names compared to those provided by instant-search directly. + // + // Additionally we are adding short circuiting logic so that we do not perform search requests + // until N characters have been submitted + // https://www.algolia.com/doc/guides/building-search-ui/going-further/conditional-requests/react/#implementing-a-proxy + searchClient.current = { + ...client, + + // We cheat with the any type here so that we do not need to bother with the extensive fields + // that are required by the response type + search: async function search( + requests: readonly AlgoliaMultipleQueriesQuery[], + ): Promise<{ results: any[] }> { + // If the query is too short, immediately return empty results + if ( + requests.every( + ({ params }) => !params || !params.query || params.query?.length < 3, + ) + ) { + return Promise.resolve({ + results: requests.map(() => ({ + hits: [], + nbHits: 0, + nbPages: 0, + page: 0, + processingTimeMS: 0, + })), + }) + } + + // Otherwise take the incoming instant-search request and transform it into a request that + // can be sent to our search route. The search route will then forward this request on to + // the RFD API backend + const request = requests[0] + return fetch( + `/search?q=${request.params + ?.query}&attributes_to_crop=${request.params?.attributesToSnippet?.join( + ',', + )}&highlight_post_tag=${request.params + ?.highlightPostTag}&highlight_pre_tag=${request.params?.highlightPreTag}`, + ).then(async (resp) => { + const data = await resp.json() + return data + }) + }, + } + }, []) + + if (searchClient.current) { + return ( + <> + } + > + + + + + + + ) + } + + return null +} + +const SearchWrapper = ({ dismissSearch }: { dismissSearch: () => void }) => { + const navigate = useNavigate() + + const { hits, results } = useHits() + + const [selectedIdx, setSelectedIdx] = useState(0) + + useEffect(() => { + // Whenever number of groups changes ensure the index is not more than that + setSelectedIdx((s) => (s > hits.length ? hits.length : s)) + }, [hits]) + + // Remove items without content + const hitsWithoutEmpty = hits.filter((hit) => hit.content !== '') + + // Group hits + const groupedHits = groupBy(hitsWithoutEmpty as RFDHit[], (hit) => hit.rfd_number) + + // Score hits + let scores: [number, number][] = [] + Object.values(groupedHits).forEach((group) => { + let sum = 0 + + group.forEach((item) => { + sum += Math.pow(item.__position, 2) + }) + + scores.push([group[0].rfd_number, Math.sqrt(sum) / group.length]) + }) + + scores = scores.sort((a, b) => a[1] - b[1]) + + // Flatten using scores for order + let flattenedHits: RFDHit[] = [] + scores.forEach((score) => { + flattenedHits.push(...groupedHits[score[0]]) + }) + + const noMatches = + results && + results.query !== '' && + results.query !== undefined && + results.query !== null && + hitsWithoutEmpty.length === 0 + + return ( +
{ + const lastIdx = hitsWithoutEmpty.length - 1 + if (e.key === 'Enter') { + const selectedItem = flattenedHits[selectedIdx] + if (!selectedItem) return + navigate(`/rfd/${selectedItem.rfd_number}#${selectedItem.anchor}`) + // needed despite key={pathname + hash} logic in case we navigate + // to the page we're already on + dismissSearch() + } else if (e.key === 'ArrowDown') { + const newIdx = selectedIdx < lastIdx ? selectedIdx + 1 : 0 + setSelectedIdx(newIdx) + e.preventDefault() // Prevent it from moving input cursor + } else if (e.key === 'ArrowUp') { + const newIdx = selectedIdx === 0 ? lastIdx : selectedIdx - 1 + setSelectedIdx(newIdx) + e.preventDefault() + } + }} + role="combobox" + tabIndex={-1} + aria-controls="TODO" + aria-expanded + className="group" + > +
0 || noMatches) && 'border-b border-b-secondary', + )} + > + + + +
+ + {(hits.length > 0 || noMatches) && ( + <> +
+ {noMatches ? ( +
+
+ +
+
+ No results for “ + {results.query}” +
+
+ ) : ( + 0} + hits={flattenedHits} + selectedIdx={selectedIdx} + /> + )} +
+ +
+ + + +
+ + )} +
+ ) +} + +const SearchBox = () => { + const { refine } = useSearchBox() + // can't use query and refine directly as value/onChange because `refine` + // seems to block — it can't take input fast enough + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + refine(inputValue) + }, [refine, inputValue]) + + return ( +
+ setInputValue(event.currentTarget.value)} + autoFocus + className="w-full bg-transparent px-4 text-sans-lg focus:!outline-none 600:text-sans-2xl" + placeholder="Search RFD contents" + /> + {inputValue !== '' && ( + + )} +
+ ) +} + +type RFDHit = Hit & { + anchor: string + hierarchy_lvl0: string + hierarchy_lvl1: string + hierarchy_lvl2: string + hierarchy_lvl3: string + rfd_number: number +} + +// Used to groups results by RFD +const groupBy = (arr: T[], key: (i: T) => K) => + arr.reduce( + (groups, item) => { + const k = key(item) + + if (!groups[k]) { + groups[k] = [] + } + + groups[k].push(item) + + return groups + }, + {} as Record, + ) + +const SearchResponse = ({ + hasHits, + hits, + selectedIdx, +}: { + hasHits: boolean + hits: RFDHit[] + selectedIdx: number +}) => { + if (hasHits) { + return ( + <> + + {hits[selectedIdx].rfd_number && ( + + )} + + ) + } + + return null +} + +const Hits = ({ data, selectedIdx }: { data: RFDHit[]; selectedIdx: number }) => { + let prevRFD = -1 + + const divRef = createRef() + const ulRef = createRef() + + useSteppedScroll(divRef, ulRef, selectedIdx) + + return ( +
+
    + {data.map((hit, index) => { + const isNewSection = hit.rfd_number !== prevRFD + const sectionIsSelected = data[selectedIdx].rfd_number === hit.rfd_number + prevRFD = hit.rfd_number + + return ( + + {isNewSection && ( +

    + {hit.hierarchy_lvl0} +

    + )} + +
    + ) + })} +
+
+ ) +} + +const HitItem = ({ hit, isSelected }: { hit: RFDHit; isSelected: boolean }) => { + const subTitleAttribute = hit.hierarchy_lvl2 ? 'hierarchy_lvl2' : 'hierarchy_lvl1' + + return ( +
+ {isSelected && ( + + ) +} + +const fetchRFD = (number: number) => { + return fetch(`/rfd/${number}/fetch`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .catch((err) => console.log(err)) +} + +const RFDPreview = ({ number }: { number: number }) => { + // Requests are cached with React Query + const query = useQuery(['rfd', number], () => fetchRFD(number), { + staleTime: 30000, + enabled: !!number, + }) + + const rfd: RfdItem | null = query.data as RfdItem + + return ( +
+ {rfd ? ( + <> +
+
Updated:
+
+ {dayjs(rfd.commit_date).format('YYYY/MM/DD h:mm A')} +
+
+ +
+ +
+ {rfd.title} +
+
    + {rfd.toc.map( + (item) => + item.level === 1 && ( +
    + + +
  • + + +
  • + ), + )} +
+
+ + ) : ( + <> +
+
+
+ +
+
+
+
    +
  • +
  • +
  • +
  • +
  • +
+
+ + )} +
+ ) +} + +interface ActionMenuHotkeyProps { + keys: Array + action: string +} + +export const ActionMenuHotkey = ({ keys, action }: ActionMenuHotkeyProps) => ( +
+
+ {keys.map((hotkey) => ( + + {hotkey} + + ))} +
+ to {action} +
+) + +export default Search diff --git a/app/components/SelectRfdCombobox.tsx b/app/components/SelectRfdCombobox.tsx new file mode 100644 index 0000000..9f39775 --- /dev/null +++ b/app/components/SelectRfdCombobox.tsx @@ -0,0 +1,262 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Link } from '@remix-run/react' +import cn from 'classnames' +import fuzzysort from 'fuzzysort' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import Icon from '~/components/Icon' +import { useKey } from '~/hooks/use-key' +import { useSteppedScroll } from '~/hooks/use-stepped-scroll' +import type { RfdItem, RfdListItem } from '~/services/rfd.server' +import { classed } from '~/utils/classed' + +const Outline = classed.div`absolute left-0 top-0 z-10 h-full w-full rounded border border-accent pointer-events-none` + +const SelectRfdCombobox = ({ + rfds, + currentRfd, +}: { + rfds: RfdListItem[] + currentRfd: RfdItem | undefined +}) => { + const [open, setOpen] = useState(false) + + // memoized to avoid render churn in useKey + const toggleCombobox = useCallback(() => setOpen(!open), [setOpen, open]) + + useKey('mod+/', toggleCombobox) + + const handleDismiss = () => setOpen(false) + + return ( +
+
+
+ RFD {currentRfd ? currentRfd.number : ''} +
+
+ {currentRfd ? currentRfd.title : 'Select a RFD'} +
+
+ + + +
+ ) +} + +const ComboboxWrapper = ({ + rfds, + onDismiss, + open, +}: { + rfds: RfdListItem[] + onDismiss: () => void + open: boolean +}) => { + const navigate = useNavigate() + + const [input, setInput] = useState('') + + const inputRef = (node: HTMLInputElement) => { + if (node) node.focus() + } + + const matchedItems: Fuzzysort.KeysResults = fuzzysort.go(input, rfds, { + threshold: -10000, + all: true, // If true, returns all results for an empty search + keys: ['title', 'number_string'], + scoreFn: (a) => { + const rfdNumber = a[1] ? parseInt(a[1].target) : null + const parsedInput = parseInt(input) + + let scoreOffset = 0 + if (!isNaN(parsedInput) && rfdNumber === parsedInput) { + scoreOffset = Infinity + } + + return Math.max( + a[0] ? a[0].score : -Infinity, + a[1] ? a[1].score + scoreOffset : -Infinity, + ) + }, + }) + + const [selectedIdx, setSelectedIdx] = useState(0) + const selectedItem: RfdListItem | undefined = matchedItems[selectedIdx]?.obj + + const handleDismiss = () => { + setInput('') + onDismiss() + } + + const divRef = useRef(null) + const ulRef = useRef(null) + + useSteppedScroll(divRef, ulRef, selectedIdx) + + return ( +
+
handleDismiss()} + /> +
{ + const lastIdx = matchedItems.length - 1 + if (e.key === 'Enter') { + if (!selectedItem) return + navigate(`/rfd/${selectedItem.number_string}`) + handleDismiss() + } else if (e.key === 'ArrowDown') { + const newIdx = selectedIdx === lastIdx ? 0 : selectedIdx + 1 + setSelectedIdx(newIdx) + e.preventDefault() // Prevent it from moving input cursor + } else if (e.key === 'ArrowUp') { + const newIdx = selectedIdx === 0 ? lastIdx : selectedIdx - 1 + setSelectedIdx(newIdx) + e.preventDefault() + } else if (e.key === 'Escape') { + handleDismiss() + } + }} + role="combobox" + tabIndex={-1} + aria-controls="TODO" + aria-expanded + > +
+ { + setSelectedIdx(0) + setInput(e.target.value) + }} + onBlur={(e) => { + // Dismiss if the users focus moves to the + // other input on the homepage + if (e.relatedTarget?.nodeName === 'INPUT') { + handleDismiss() + } + }} + placeholder="Search" + spellCheck="false" + className="mousetrap h-12 w-full appearance-none rounded border-none px-3 text-sans-lg text-default bg-raise focus:outline-none focus:outline-offset-0 600:h-auto 600:py-3 600:text-sans-md" + /> + +
+
+
    *:last-child_.menu-item]:border-b-0')} + > + {matchedItems.length > 0 ? ( + matchedItems.map((rfd: Fuzzysort.KeysResult, index: number) => { + return ( + 0} + onClick={() => handleDismiss()} + /> + ) + }) + ) : ( +
    + No matches found +
    + )} +
+
+
+
+ ) +} + +const ComboboxItem = ({ + rfd, + selected, + onClick, + isDirty, +}: { + rfd: RfdListItem + selected: boolean + onClick: () => void + isDirty: boolean +}) => { + const [shouldPrefetch, setShouldPrefetch] = useState(false) + + const timer = useRef(null) + + function clear() { + if (timer.current) clearTimeout(timer.current) + timer.current = null + } + + // Programmatically prefetching items in the jump to menu + // Only if the user has typed something, with a timeout to + // avoid prefetching everything + useEffect(() => { + if (selected && isDirty) { + timer.current = setTimeout(() => setShouldPrefetch(true), 250) + } else { + clear() + setShouldPrefetch(false) + } + + return clear + }, [selected, isDirty]) + + return ( + +
  • + {selected && } +
    RFD {rfd.number}
    +
    + {rfd.title} +
    +
  • + + ) +} + +export default SelectRfdCombobox diff --git a/app/components/Spinner.tsx b/app/components/Spinner.tsx new file mode 100644 index 0000000..5142486 --- /dev/null +++ b/app/components/Spinner.tsx @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import cn from 'classnames' + +const Spinner = ({ className }: { className?: string }) => { + const frameSize = 12 + const center = 6 + const radius = 5 + const strokeWidth = 2 + return ( + + + + + ) +} + +export default Spinner diff --git a/app/components/StatusBadge.tsx b/app/components/StatusBadge.tsx new file mode 100644 index 0000000..a94bdfe --- /dev/null +++ b/app/components/StatusBadge.tsx @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Badge, type BadgeColor } from '@oxide/design-system' + +const StatusBadge = ({ label }: { label: string }) => { + let color: BadgeColor | undefined + + switch (label) { + case 'prediscussion': + color = 'purple' + break + case 'ideation': + color = 'notice' + break + case 'abandoned': + color = 'neutral' + break + case 'discussion': + color = 'blue' + break + default: + color = 'default' + } + + return {label} +} + +export default StatusBadge diff --git a/app/components/Suggested.tsx b/app/components/Suggested.tsx new file mode 100644 index 0000000..c741951 --- /dev/null +++ b/app/components/Suggested.tsx @@ -0,0 +1,101 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Link } from '@remix-run/react' +import cn from 'classnames' +import { cloneElement, type ReactElement, type ReactNode } from 'react' + +import Icon from '~/components/Icon' +import { type RfdListItem } from '~/services/rfd.server' + +import type { Author } from './rfd/RfdPreview' + +const Comma = () => , + +export const SuggestedAuthors = ({ authors }: { authors: Author[] }) => { + if (authors.length === 0) return null + + return ( + } color="purple"> +
    + Filter RFDs from: + {authors + .filter((a) => a.email) + .map((author, index) => ( + + {author.name} + {index < authors.length - 1 && } + + ))} +
    +
    + ) +} + +export const SuggestedLabels = ({ labels }: { labels: string[] }) => { + if (labels.length === 0) return null + + return ( + } color="blue"> +
    + Filter RFDs labeled: + {labels.map((label, index) => ( + + {label} + {index < labels.length - 1 && } + + ))} +
    +
    + ) +} + +export const ExactMatch = ({ rfd }: { rfd: RfdListItem }) => ( + } color="green"> +
    + RFD {rfd.number}:{' '} + + {rfd.title} + +
    +
    +) + +export const SuggestedTemplate = ({ + children, + icon, + color, +}: { + children: ReactNode + icon: ReactElement + color: string +}) => ( +
    +
    + {cloneElement(icon, { + className: `mr-2 flex-shrink-0 text-accent-tertiary`, + })} + {children} +
    +
    +) diff --git a/app/components/TableOfContents.tsx b/app/components/TableOfContents.tsx new file mode 100644 index 0000000..3198e01 --- /dev/null +++ b/app/components/TableOfContents.tsx @@ -0,0 +1,282 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { AdocTypes } from '@oxide/react-asciidoc' +import * as Accordion from '@radix-ui/react-accordion' +import { Link } from '@remix-run/react' +import cn from 'classnames' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { isTruthy } from '~/utils/isTruthy' + +import { stripAnchors } from './AsciidocBlocks/Section' +import Icon from './Icon' + +// Create threshold steps that trigger intersection events every 0.5%. This is needed to provide +// decent measurements on very tall elements. In the future we could instead compute the needed +// thresholds on a per element basis so that we are measuring per pixel instead of per percent. +const THRESHOLD = [...Array(200).keys()].map((n) => n / 200) + +// Uses the asciidoc generated classes to find the closest section wrapping parent element +function findParentSection(element: Element): Element | null { + let sect2Wrapper = element.closest('.sect2') + return sect2Wrapper ? sect2Wrapper : element.closest('.sect1') +} + +export function useIntersectionObserver( + elements: Element[], + callback: IntersectionObserverCallback, + options: IntersectionObserverInit, +) { + let [observer, setObserver] = useState(null) + + // Create the observer in an effect so we can tear it down when this unmounts + useEffect(() => { + let observer = new IntersectionObserver(callback, options) + setObserver(observer) + + return () => { + observer.disconnect() + } + }, [callback, options]) + + // Bind the elements that should be listened for + useEffect(() => { + if (observer) { + for (let element of elements) { + observer.observe(element) + } + } + + return () => { + if (observer) { + for (let element of elements) { + observer.unobserve(element) + } + } + } + }, [elements, observer]) + + return observer +} + +// This hook will call the provided callback whenever a section that is identified by a heading is +// determined to be "active". There are three triggering rules for determining when a section +// activates: +// 1. A section header reaches an imaginary line that is 10% down the page +// 2. 90% of the top 50% of the page is covered a single section +// 3. A section is contained entirely in the top 50% of the page +// +// Two IntersectionObservers are used for tracking these conditions. One for tracking the first case, +// and a second for tracking the other two. +export function useActiveSectionTracking( + initialSections: Element[], + onSectionChange: (element: Element) => void, +) { + let [sections, setSections] = useState([]) + let [sectionWrappers, setSectionWrappers] = useState<(Element | null)[]>( + initialSections.map(findParentSection), + ) + + // The caller only provides the hook with section headers and so we need to map those to their + // relevant containing sections + useEffect(() => { + setSectionWrappers(sections.map(findParentSection)) + }, [sections]) + + // Create the heading tracker + let headingActivator = useCallback( + (entries: IntersectionObserverEntry[]) => { + for (let entry of entries) { + if (entry.isIntersecting) { + onSectionChange(entry.target) + } + } + }, + [onSectionChange], + ) + + // Imaginary 0px line at 10% from top the of the screen + let headingSettings = useMemo(() => ({ rootMargin: `-10% 0px -90% 0px` }), []) + useIntersectionObserver(sections, headingActivator, headingSettings) + + // Create the section tracker + let wrapperActivator = useCallback( + (entries: IntersectionObserverEntry[]) => { + for (let entry of entries) { + // Compute the screen space covered by this entry + let covered = entry.intersectionRect.height / (entry.rootBounds?.height || 1) + + // If either this element is entirely encapsulated by the top 50% of the screen or it is + // covering at least 90% of the top 50% of the screen then we consider the element active. + // We specifically do not test for 100% coverage as we are not guaranteed to be given a + // chance to measure at every percentage point. 90% seems to be a safe threshold, but could + // likely use some tuning + if (entry.intersectionRatio === 1 || covered >= 0.9) { + // Map this section back to its relevant header to activate it + let index = sectionWrappers.findIndex((wrapper) => wrapper === entry.target) + onSectionChange(sections[index]) + } + } + }, + [sections, sectionWrappers, onSectionChange], + ) + + // Watch the top 50% of the screen, and measure the intersection every 0.5% (defined by THRESHOLD) + let wrapperSettings = useMemo( + () => ({ threshold: THRESHOLD, rootMargin: `0px 0px -50% 0px` }), + [], + ) + + // We need to ensure that we are only sending valid sections to be observed + let bindableWrappers = useMemo(() => sectionWrappers.filter(isTruthy), [sectionWrappers]) + + useIntersectionObserver(bindableWrappers, wrapperActivator, wrapperSettings) + + return { + setSections, + } +} + +export const generateTableOfContents = (sections: AdocTypes.Section[]) => { + let toc: TocItem[] = [] + + if (sections.length < 1) return toc + + for (let section of sections) { + generateSection(toc, section) + } + + return toc +} + +export type TocItem = { + id: string + level: number + title: string +} + +const generateSection = (toc: TocItem[], section: AdocTypes.Section) => { + toc.push({ + id: section.getId(), + level: section.getLevel(), + title: section.getTitle() || '', + }) + + if (section.hasSections()) { + const sections = section.getSections() + + for (let section of sections) { + generateSection(toc, section) + } + } +} + +export const DesktopOutline = ({ + toc, + activeItem, +}: { + toc: TocItem[] + activeItem: string +}) => { + if (toc && toc.length > 0) { + return ( +
      + {toc.map((item) => ( +
    • 2 && 'hidden')} + > + + + +
    • + ))} +
    + ) + } + + return null +} + +export const SmallScreenOutline = ({ + toc, + activeItem, + title, +}: { + toc: TocItem[] + activeItem: string + title: string +}) => { + if (toc && toc.length > 0) { + return ( + + + + + Table of Contents{' '} + + + + + +
    + {toc.map((item) => ( +
  • 2 && 'hidden', + )} + > + + + +
  • + ))} +
    +
    +
    +
    + ) + } + + return null +} diff --git a/app/components/home/FilterDropdown.tsx b/app/components/home/FilterDropdown.tsx new file mode 100644 index 0000000..6c2d181 --- /dev/null +++ b/app/components/home/FilterDropdown.tsx @@ -0,0 +1,180 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Badge, type BadgeColor } from '@oxide/design-system' +import * as Dropdown from '@radix-ui/react-dropdown-menu' +import { useSearchParams } from '@remix-run/react' +import { type ReactNode } from 'react' + +import { + DropdownItem, + DropdownMenu, + DropdownSubMenu, + DropdownSubTrigger, +} from '~/components/Dropdown' +import Icon from '~/components/Icon' +import { useRootLoaderData } from '~/root' +import { classed } from '~/utils/classed' + +const Outline = classed.div`absolute left-0 top-0 z-10 h-[calc(100%+1px)] w-full rounded border border-accent pointer-events-none` + +const FilterDropdown = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const authors = useRootLoaderData().authors + const labels = useRootLoaderData().labels + + const authorNameParam = searchParams.get('authorName') + const authorEmailParam = searchParams.get('authorEmail') + const labelParam = searchParams.get('label') + + const handleFilterAuthor = (email: string, name: string) => { + const sEmail = searchParams.get('authorEmail') + const sName = searchParams.get('authorName') + + if (sEmail === email || sName === name) { + searchParams.delete('authorEmail') + searchParams.delete('authorName') + } else { + searchParams.set('authorEmail', email) + searchParams.set('authorName', name) + } + setSearchParams(searchParams, { replace: true }) + } + + const clearAuthor = () => { + searchParams.delete('authorEmail') + searchParams.delete('authorName') + setSearchParams(searchParams, { replace: true }) + } + + const handleFilterLabel = (label: string) => { + if (labelParam === label) { + searchParams.delete('label') + } else { + searchParams.set('label', label) + } + setSearchParams(searchParams, { replace: true }) + } + + const clearLabel = () => { + searchParams.delete('label') + setSearchParams(searchParams, { replace: true }) + } + + return ( +
    + + + + + + + + Authors + + {authors + .filter((a) => a.email) + .map((author) => { + const selected = + authorNameParam === author.name || authorEmailParam === author.email + + return ( + handleFilterAuthor(author.email, author.name)} + > + {author.name} + + ) + })} + + + + Labels + + {labels.map((label) => { + const selected = labelParam === label + + return ( + handleFilterLabel(label)} + > + {label} + + ) + })} + + + + + + {(authorNameParam || authorEmailParam) && ( + <> +
    Author:
    + + {authorNameParam || authorEmailParam} + + + )} + + {labelParam && ( + <> +
    Label:
    + + {labelParam} + + + )} +
    + ) +} + +const DropdownFilterItem = ({ + onSelect, + selected, + children, +}: { + onSelect: () => void + selected: boolean + children: ReactNode +}) => ( + + {selected && } +
    +
    + {children} +
    +
    +
    +) + +const FilterBadge = ({ + children, + onClick, + color = 'default', +}: { + children: ReactNode + onClick: () => void + color?: BadgeColor +}) => ( + +
    {children}
    + +
    +) + +export default FilterDropdown diff --git a/app/components/rfd/AccessWarning.tsx b/app/components/rfd/AccessWarning.tsx new file mode 100644 index 0000000..3a32724 --- /dev/null +++ b/app/components/rfd/AccessWarning.tsx @@ -0,0 +1,46 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Fragment } from 'react' + +import Icon from '~/components/Icon' + +const AccessWarning = ({ groups }: { groups: string[] }) => { + if (groups.length === 0) return null + + const formatAllowList = (message: string, index: number) => { + if (index < groups.length - 1) { + return ( + <> + {message} + , + + ) + } else { + return message + } + } + + return ( +
    +
    + +
    + This RFD can be accessed by the following groups: + [ + {groups.map((message, index) => ( + {formatAllowList(message, index)} + ))} + ] +
    +
    +
    + ) +} + +export default AccessWarning diff --git a/app/components/rfd/MoreDropdown.tsx b/app/components/rfd/MoreDropdown.tsx new file mode 100644 index 0000000..d849402 --- /dev/null +++ b/app/components/rfd/MoreDropdown.tsx @@ -0,0 +1,73 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import * as Dropdown from '@radix-ui/react-dropdown-menu' +import { useLoaderData } from '@remix-run/react' +import { useEffect, type Dispatch, type SetStateAction } from 'react' + +import type { loader } from '~/routes/rfd.$slug' + +import { DropdownItem, DropdownLink, DropdownMenu } from '../Dropdown' +import Icon from '../Icon' + +const MoreDropdown = ({ + setConfidential, + confidential, +}: { + setConfidential: Dispatch> + confidential: Boolean +}) => { + const { rfd } = useLoaderData() + + useEffect(() => { + if (!confidential) return + window.print() + setConfidential(false) + }, [confidential, setConfidential]) + + return ( + + + + + + + + View discussion + + + + View on GitHub + + + {rfd.link && ( + + View AsciiDoc source + + )} + + + View PDF + + + { + setConfidential(true) + }} + > + Print confidential PDF + + + + ) +} + +export default MoreDropdown diff --git a/app/components/rfd/RfdDiscussionDialog.tsx b/app/components/rfd/RfdDiscussionDialog.tsx new file mode 100644 index 0000000..c0664d3 --- /dev/null +++ b/app/components/rfd/RfdDiscussionDialog.tsx @@ -0,0 +1,487 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + Dialog, + DialogDismiss, + DialogHeading, + useDialogStore, + type DialogStore, +} from '@ariakit/react' +import cn from 'classnames' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { marked } from 'marked' +import { useMemo, useRef } from 'react' + +import Icon from '~/components/Icon' +import { useIsOverflow } from '~/hooks/use-is-overflow' +import type { + IssueCommentType, + ListIssueCommentsType, + ListReviewsCommentsType, + ListReviewsType, + ReviewType, +} from '~/services/rfd.server' + +import { GotoIcon } from '../CustomIcons' +import Spinner from '../Spinner' +import { CommentThreadBlock, matchCommentToBlock } from './RfdInlineComments' +import { calcOffset } from './RfdPreview' + +dayjs.extend(relativeTime) + +type ReviewDiscussion = { + review: ReviewType + issueComment: null + comments: CommentThread + createdAt: Date +} + +type IssueComment = { + review: null + issueComment: IssueCommentType + comments: null + createdAt: Date +} + +type Discussions = (ReviewDiscussion | IssueComment)[] +type CommentThread = Record + +const RfdDiscussionDialog = ({ + reviews, + comments, + title, + rfdNumber, + pullNumber, + prComments, +}: { + reviews: ListReviewsType + comments: ListReviewsCommentsType + prComments: ListIssueCommentsType + title: string + rfdNumber: number + pullNumber: number +}) => { + const dialog = useDialogStore({ animated: true }) + + const discussions = useMemo((): Discussions => { + if (!reviews || !comments || !prComments) { + return [] + } + + const threads: CommentThread = {} + comments.forEach((comment) => { + // If it is check if that comment already has it's own thread + const parentId = comment.in_reply_to_id + + // If it doesn't have a `in_reply_to_id` then it is the parent + // Create a new thread with this comment + if (!parentId) { + threads[comment.id] = [comment] + return + } + + const thread = threads[parentId] + + // Todo: check if comments are always in chronological order + // In case we miss a comment that comes before its parent + // If it does add this comment to that thread + if (thread) { + threads[parentId].push(comment) + } + }) + + // Now we group the threads by the review + const discussions: Record = {} + Object.keys(threads).forEach((key) => { + const thread = threads[key] + const reviewId = thread[0].pull_request_review_id + const review = reviews.find((review) => review.id === reviewId) + + if (!thread || !reviewId || !review) { + return + } + + const discussion = discussions[reviewId] + + // We group threads by review + // Each review can contain many threads + // Which can each contain many comments + if (discussion && discussion.comments) { + discussions[reviewId].comments[key] = thread + } else { + discussions[reviewId] = { + review, + issueComment: null, + comments: { key: thread }, + createdAt: new Date(review.submitted_at || ''), + } + } + }) + + let discussionsArray: Discussions = Object.values(discussions) + + // Add comments that are not attached to a review + prComments.forEach((comment) => { + if (!comment) { + return + } + + const obj: IssueComment = { + review: null, + issueComment: comment, + comments: null, + createdAt: new Date(comment.created_at), + } + + discussionsArray.push(obj) + }) + + discussionsArray.sort((a, b) => { + return a.createdAt.getTime() - b.createdAt.getTime() + }) + + return discussionsArray + }, [reviews, comments, prComments]) + + return ( + <> + + + + + ) +} + +export const CommentCount = ({ + count, + isLoading, + onClick, + error = false, +}: { + count: number + isLoading: boolean + onClick: () => void + error?: boolean +}) => { + return ( +
    + +
    + ) +} + +const DialogContent = ({ + dialogStore, + rfdNumber, + title, + discussions, + pullNumber, +}: { + dialogStore: DialogStore + rfdNumber: number + title: string + discussions: Discussions + pullNumber: number +}) => { + return ( + + +
    +
    + RFD {rfdNumber} {title} +
    + + + +
    + +
    #{pullNumber}
    +
    +
    + +
    + ) +} + +const DiscussionReviewGroup = ({ + discussions, + pullNumber, +}: { + discussions: Discussions + pullNumber: number +}) => { + const overflowRef = useRef(null) + const { scrollStart } = useIsOverflow(overflowRef) + + const reviewCount = Object.keys(discussions).length + + return ( + <> +
    0 ? 'opacity-0' : 'opacity-100 transition-opacity', + )} + /> +
    + {reviewCount > 0 ? ( + <> + {discussions.map((discussion, index) => { + const count = discussions.length + if (discussion.review) { + return ( + + ) + } else { + return ( + + ) + } + })} + + ) : ( +
    +
    +
    + +
    +

    Nothing to see here

    +

    + This discussion has no reviews or comments +

    + + Add a comment + +
    +
    + )} +
    + + ) +} + +const DiscussionReview = ({ + data, + isLast, +}: { + data: ReviewDiscussion + isLast: boolean +}) => { + if (!data.review || !data.review.user) { + return null + } + + const gotoBlock = (line: number) => { + const lineNumbers = document.querySelectorAll('[data-lineno]') + const block = matchCommentToBlock(line, lineNumbers as NodeListOf) + if (block) { + window.scroll(0, calcOffset(block).top) + } + } + + return ( +
    + {/* Timeline line */} + {!isLast && ( +
    + )} + + {/* Review header */} +
    + {`Avatar +
    + +
    + reviewed on + +
    +
    +
    + + {/* Review body (if it exists) */} + {data.review.body && ( +
    +
    + + {data.review.user.login} + + left a comment +
    + +
    +
    + )} + + {/* Review comments */} + {Object.keys(data.comments).map((key) => { + const thread = data.comments[key] + return ( +
    + {thread[0].line && ( +
    + gotoBlock(thread[0].line!)} + className="group sticky top-0" + > +
    + +
    +
    +
    + )} +
    + {}} + isOverlay={false} + isOutdated={thread[0].line === null} + /> +
    +
    + ) + })} +
    + ) +} + +const DiscussionIssueComment = ({ + data, + isLast, +}: { + data: IssueCommentType + isLast: boolean +}) => { + if (!data || !data.user) { + return null + } + + return ( +
    + {/* Timeline line */} + {!isLast && ( +
    + )} + + {/* Review header */} +
    + {`Avatar + +
    +
    + + {data.user.login} + + + commented on + + +
    + +
    +
    +
    +
    + ) +} + +export default RfdDiscussionDialog diff --git a/app/components/rfd/RfdInlineComments.tsx b/app/components/rfd/RfdInlineComments.tsx new file mode 100644 index 0000000..37f154c --- /dev/null +++ b/app/components/rfd/RfdInlineComments.tsx @@ -0,0 +1,577 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + autoUpdate, + FloatingFocusManager, + offset, + useClick, + useDismiss, + useFloating, + useId, + useInteractions, + useRole, +} from '@floating-ui/react' +import cn from 'classnames' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import uniqBy from 'lodash/uniqBy' +import { marked } from 'marked' +import { useEffect, useMemo, useState } from 'react' +import { renderToString } from 'react-dom/server' +import diff from 'simple-text-diff' + +import Container from '~/components/Container' +import Icon from '~/components/Icon' +import useWindowSize from '~/hooks/use-window-size' +import type { ListReviewsCommentsType, ReviewCommentsType } from '~/services/rfd.server' + +import { calcOffset } from './RfdPreview' + +dayjs.extend(relativeTime) + +type CommentThreadData = { + data: ListReviewsCommentsType + targetEl: HTMLElement | null + offset: number | null +} + +type Comment = { + [key: string]: CommentThreadData +} + +export const matchCommentToBlock = ( + line: number | null | undefined, + lineNumbers: NodeListOf, +): HTMLElement | null => { + let block: HTMLElement | null = null + let prevDelta = 999999 + + lineNumbers.forEach((lineNumberEl) => { + const lineno = parseInt(lineNumberEl.dataset.lineno || '-1') + + if (!lineno || lineno === -1 || !line) { + return + } + + // Check if there's an exact match + if (lineno === line) { + block = lineNumberEl + } + + const lineNumberDelta = line - lineno + + // If not, check find the match with the closest lowest number + // Optimise by cancelling the loop if lineNumberDelta is greater + // Need to check if the elements are ordered by their line numbers + // in the order that they show up on the page + if (lineNumberDelta < prevDelta && lineNumberDelta > 0) { + block = lineNumberEl + prevDelta = lineNumberDelta + } + }) + + return block +} + +const RfdInlineComments = ({ comments }: { comments: ListReviewsCommentsType }) => { + const [isLoaded, setIsLoaded] = useState(false) + const [inlineComments, setInlineComments] = useState({}) + + useEffect(() => { + setIsLoaded(true) + }, []) + + useEffect(() => { + // Get a list of elements with data-lineno + // This is used to attach comments to their associated rendered line + let lineNumbers = + typeof document !== 'undefined' ? document.querySelectorAll('[data-lineno]') : [] + + const newComments: Comment = {} + + comments.forEach((comment) => { + const block = matchCommentToBlock( + comment.line, + lineNumbers as NodeListOf, + ) + + // If a comment doesn't have a line it is because the line + // has changed and the comment is outdated + if (!comment.line) { + return + } + + if (block) { + if (!newComments[comment.line]) { + // Group by comment thread + newComments[comment.line] = { + data: [], + targetEl: block, + offset: 0, + } + } + newComments[comment.line].data.push(comment) + } + }) + + // If an element shares the same block, find them and offset the position accordingly + Object.keys(newComments).forEach((key) => { + const comment = newComments[key] + const groupedCommentKeys = Object.keys(newComments).filter((altKey) => { + const altComment = newComments[altKey] + return altComment.targetEl === comment.targetEl + }) + + groupedCommentKeys.forEach((key, index) => { + const groupedComment = newComments[key] + groupedComment.offset = index * 32 + }) + }) + + // Group comments by block + // in_reply_to_id + setInlineComments(newComments) + }, [comments]) + + if (!inlineComments || !isLoaded) return null + + return ( + + {Object.keys(inlineComments).map((key, index) => { + const commentThread = inlineComments[parseInt(key)] + return ( + + ) + })} + + ) +} + +type CommentThreadProps = { + commentThread: CommentThreadData + isLoaded: boolean + index: number +} + +const CommentThread = ({ commentThread, isLoaded, index }: CommentThreadProps) => { + const [open, setOpen] = useState(false) + const { size, hasLargeScreen } = useWindowSize() + + const { context, x, y, reference, floating, strategy } = useFloating({ + open, + onOpenChange: setOpen, + + placement: 'bottom-start', + whileElementsMounted: autoUpdate, + middleware: [offset(10)], + }) + + const click = useClick(context) + const dismiss = useDismiss(context, { + outsidePressEvent: 'mousedown', + }) + const role = useRole(context) + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]) + + const headingId = useId() + + // Recalculate the tooltip position when the screen size changes + const offsetTop = useMemo( + () => (commentThread.targetEl ? calcOffset(commentThread.targetEl).top : 0), + // it gets mad when you have extra deps, which is silly + // eslint-disable-next-line react-hooks/exhaustive-deps + [size, commentThread.targetEl], + ) + + const users = useMemo( + () => + uniqBy( + commentThread.data.map((c) => c.user), + (u) => u.login, + ), + [commentThread], + ) + + if (!commentThread.targetEl || !hasLargeScreen) { + return null + } + + return ( +
    + + + {open && ( + +
    + setOpen(false)} + isOverlay={true} + /> +
    +
    + )} +
    + ) +} + +type Change = 'add' | 'remove' | null + +const CodeSuggestion = ({ + original, + suggestion, + isOverlay, +}: { + original: string + suggestion: string + isOverlay: boolean +}) => { + const textDiff = diff.diffPatch(original, suggestion) + return ( +
    +
    + Suggestion +
    + + +
    + ) +} + +export const CommentThreadBlock = ({ + path, + line, + startLine, + diffHunk, + comments, + htmlUrl, + handleDismiss, + isOverlay, + isOutdated = false, +}: { + path: string + line: number | undefined + startLine: number | null | undefined + diffHunk: string + comments: ReviewCommentsType[] + htmlUrl: string + handleDismiss: () => void + isOverlay: boolean + isOutdated?: boolean +}) => { + let lineCount = 4 + let _startLine = 1 + if (startLine && line) { + _startLine = startLine + lineCount = line - startLine + 1 + } else if (line) { + _startLine = line - lineCount + 1 + } + + // Turn `diff_hunk` into an array of lines + let lines = diffHunk.split('\n') + + // Don't try to render more lines than there are in the `diff_hunk` + // -1 because the first line is metadata + lineCount = Math.min(lineCount, lines.length - 1) + _startLine = Math.max(1, _startLine) + + // Get the number of lines we want, starting from the end and working backwards + // The API delivers the diff with the last line being the selected line, and the + // context of that line before it. GitHub shows 4 lines by default + // todo: handle first line in a file + lines = lines.slice(lineCount * -1) + + return ( +
    + {/* Meta */} + {/* {isOverlay && ( */} + + {/* )} */} + + {/* Code */} +
    + {lines.map((line, index) => { + let change: Change = null + + if (line[0] === '+') { + change = 'add' + } else if (line[0] === '-') { + change = 'remove' + } + + let code = change ? line.slice(1) : line + + return ( + + ) + })} +
    + + {/* Comments */} +
    + {comments.map((comment) => { + let original = lines[lines.length - 1] + + if (original[0] === '+' || original[0] === '-') { + original = original.slice(1) + } + + const renderer = { + code(code: string, infostring: string) { + const match = (infostring || '').match(/\S*/) + const lang = match ? match[0] : '' + let _code = code.replace(/\n$/, '') + '\n' + + if (lang === 'suggestion') { + return renderToString( + , + ) + } + + const cls = lang ? `class="lang-${lang}"` : '' + + return `
    ${_code}
    \n` + }, + } + + marked.use({ renderer }) + + return ( +
    + {`Avatar + +
    +
    +
    + +
    + +
    +
    +
    +
    + + {comment.reactions && } +
    +
    + ) + })} +
    +
    + ) +} + +type Reactions = NonNullable + +/** Filter out the keys in `Reactions` that aren't emoji counts */ +function getEmojiCounts(reactions: Reactions) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { url, total_count, ...emojis } = reactions + return emojis +} + +type EmojiKey = keyof ReturnType + +const reactionEmoji: Record = { + '+1': '👍', + '-1': '👎', + laugh: '😄', + hooray: '🎉', + rocket: '🚀', + confused: '😕', + heart: '❤', + eyes: '👀', +} + +const CommentReactions = ({ reactions }: { reactions: Reactions }) => { + if (reactions.total_count === 0) return null + + return ( +
    + {Object.entries(getEmojiCounts(reactions)).map(([key, count]) => { + if (count === 0) return null + + const emoji = reactionEmoji[key as EmojiKey] + + return ( +
    + {emoji} + {count} +
    + ) + })} +
    + ) +} + +const CodeLine = ({ + change, + code, + lineNumber, +}: { + change: Change + code: string + lineNumber?: number | null +}) => { + return ( +
    + {lineNumber && ( +
    + {lineNumber} +
    + )} +
    +
    + {change === 'add' && '+'} + {change === 'remove' && '-'} +
    +
    +
    +
    + ) +} + +export default RfdInlineComments diff --git a/app/components/rfd/RfdPreview.tsx b/app/components/rfd/RfdPreview.tsx new file mode 100644 index 0000000..feea170 --- /dev/null +++ b/app/components/rfd/RfdPreview.tsx @@ -0,0 +1,289 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Link } from '@remix-run/react' +import cn from 'classnames' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { Fragment, useCallback, useEffect, useRef, useState } from 'react' + +import { useRootLoaderData } from '~/root' +import type { RfdListItem } from '~/services/rfd.server' + +dayjs.extend(relativeTime) + +const regexes = [ + /#rfd[-_]?([0-9]{1,4})/, + /^https:\/\/rfd\.shared\.oxide\.computer\/rfd\/(\d+)/, + /^https:\/\/([0-9]+)\.rfd\.oxide\.computer/, + /(oxide).*rfd\/(\d+)/, +] + +// Gets offset top for nested elements +// e.g. anchors inside tables +export function calcOffset(element: HTMLAnchorElement | HTMLElement) { + let el: HTMLAnchorElement | HTMLElement | null = element + + var x = el.offsetLeft + var y = el.offsetTop + + while ((el = el.offsetParent as HTMLElement)) { + // We want to stop when we reach the parent to the element + if (el.nodeName === 'MAIN') { + break + } + x += el.offsetLeft + y += el.offsetTop + } + + return { left: x, top: y } +} + +const RfdPreview = ({ currentRfd }: { currentRfd: number }) => { + const [rfdPreview, setRfdPreview] = useState(null) + const [rfdAnchor, setRfdAnchor] = useState(null) + const [rfdPreviewPos, setRfdPreviewPos] = useState<{ left: number; top: number }>({ + left: 0, + top: 0, + }) + const { rfds } = useRootLoaderData() + + const timeoutRef = useRef() + + const showRfdHover = useCallback( + (e: MouseEvent, href: string) => { + clearTimeout(timeoutRef.current) + + const showRfdPreview = () => { + const el = e.target as HTMLAnchorElement // making a little assumption here + let hrefMatch: string | null = null + + for (let regex of regexes) { + const match = href.match(regex)?.at(-1) + + if (match) { + hrefMatch = match + break + } + } + + const rfdNum = hrefMatch ? parseInt(hrefMatch, 10) : null + if (!rfdNum || Number.isNaN(rfdNum) || rfdNum === currentRfd) return // sort of validate that assumption + + const matchedRfd = rfds.find((rfd) => rfd.number === rfdNum) + if (!matchedRfd) return + + const offset = calcOffset(el) + + setRfdPreview(matchedRfd) + setRfdPreviewPos({ left: offset.left, top: offset.top }) + setRfdAnchor(el) + } + + // Adds a delay of 125ms before opening the preview + // Avoids opening accidentally as a user scans through the text + timeoutRef.current = setTimeout(showRfdPreview, 125) + }, + [rfds, currentRfd], + ) + + const floatingEl = useRef(null) + + type Point = [number, number] + type Polygon = Point[] + + const handleHover = useCallback( + (event: MouseEvent) => { + if (!rfdAnchor || !floatingEl || !floatingEl.current) { + return + } + + // 1┌────────────┐2 + // └────────────┘\ + // | \ + // ┌───────────────┐3 + // │ │ + // 5└───────────────┘4 + // + // Returns a set of points for each corner of a polygon + // that the cursor can safely be within without closing + // the floating preview. Plus a buffer of 10px to avoid + // it being too sensitive + const getPolygon = (anchorRect: DOMRect, floatingRect: DOMRect): Array => { + const buffer = 10 + const p1: Point = [anchorRect.left - buffer, anchorRect.top - buffer] + const p2: Point = [ + anchorRect.left + anchorRect.width + buffer, + anchorRect.top + anchorRect.height - buffer, + ] + const p3: Point = [ + floatingRect.left + floatingRect.width + buffer, + floatingRect.top - buffer, + ] + const p4: Point = [ + floatingRect.left + floatingRect.width + buffer, + floatingRect.top + floatingRect.height + buffer, + ] + const p5: Point = [ + floatingRect.left - buffer, + floatingRect.top + floatingRect.height + buffer, + ] + return [p1, p2, p3, p4, p5] + } + + const isPointInPolygon = (point: Point, polygon: Polygon) => { + const [x, y] = point + let isInside = false + const length = polygon.length + for (let i = 0, j = length - 1; i < length; j = i++) { + const [xi, yi] = polygon[i] || [0, 0] + const [xj, yj] = polygon[j] || [0, 0] + const intersect = + // prettier-ignore + (yi >= y) !== (yj >= y) && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi + if (intersect) { + isInside = !isInside + } + } + return isInside + } + + const cursor: Point = [event.clientX, event.clientY] + const floatingRect = floatingEl.current.getBoundingClientRect() + const anchorRect = rfdAnchor.getBoundingClientRect() + + const polygon: Polygon = getPolygon(anchorRect, floatingRect) + + const isInside = isPointInPolygon(cursor, polygon) + + if (!isInside) { + setRfdPreview(null) + window.removeEventListener('mousemove', handleHover) + } + }, + [rfdAnchor], + ) + + useEffect(() => { + if (!rfdPreview) { + return + } + + window.addEventListener('mousemove', handleHover) + return () => window.removeEventListener('mousemove', handleHover) + }, [rfdPreview, handleHover]) + + useEffect(() => { + const links = document.querySelectorAll('.asciidoc-body#content a') + + function handleClearTimeout() { + clearTimeout(timeoutRef.current) + } + + const removes: (() => void)[] = [] + + links.forEach((el) => { + const showHover = (e: MouseEvent) => showRfdHover(e, el.href) + el.addEventListener('mouseover', showHover) + el.addEventListener('mouseout', handleClearTimeout) + removes.push(() => { + el.removeEventListener('mouseover', showHover) + el.removeEventListener('mouseout', handleClearTimeout) + }) + }) + + return () => removes.forEach((remove) => remove()) + }, [showRfdHover]) + + if (!rfdPreview) return null + + const { title, number, state, commit_date, number_string } = rfdPreview + const authors = generateAuthors(rfdPreview.authors || '') + return ( +
    + + {number} + +
    + + {title} + +
    + {authors.map((author, index) => ( + + + {author.name} + + {index < authors.length - 1 && ', '} + + ))} +
    +
    +
    {state.charAt(0).toUpperCase() + state.slice(1)}
    + +
    {dayjs(commit_date).fromNow()}
    +
    +
    +
    + ) +} + +export type Author = { + name: string + email: string +} + +export const generateAuthors = (authors: string): Author[] => { + // Officially asciidoc uses the semicolon for multiple authors + // we are using commas in most the documents I have seen + // chose to parse both rather than update the RFDs since that would + // be tedious work for little gain. This does means that a user cannot + // mix both methods. But what kind of person would do such a thing. + let splitChar = ',' + + if (authors.includes(';')) { + splitChar = ';' + } + + let array = authors.split(splitChar).map((author) => { + const regex = /<(.+)>/ + const matches = author.match(regex) + const name = author.replace(regex, '').trim() + const email = matches ? matches[1] : '' + + return { name, email } + }) + + // Remove extra items + // Fixes empty item when a single author has a ; or , at the end + return array.filter((author) => author.name !== '') +} + +export default RfdPreview diff --git a/app/components/rfd/index.css b/app/components/rfd/index.css new file mode 100644 index 0000000..907797b --- /dev/null +++ b/app/components/rfd/index.css @@ -0,0 +1,74 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +.dialog { + opacity: 0; + transition-property: opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 100ms; + transform: translate3d(50%, 0px, 0px); +} + +.dialog[data-enter] { + opacity: 1; + transition-duration: 100ms; + transform: translate3d(0%, 0px, 0px); +} + +.dialog[data-leave] { + transition-duration: 50ms; +} + +.spinner { + --radius: 4; + --PI: 3.14159265358979; + --circumference: calc(var(--PI) * var(--radius) * 2px); + animation: rotate 5s linear infinite; +} + +.spinner .path { + stroke-dasharray: var(--circumference); + transform-origin: center; + animation: dash 4s ease-in-out infinite; + stroke: var(--content-accent); +} + +@media (prefers-reduced-motion) { + .spinner { + animation: rotate 6s linear infinite; + } + + .spinner .path { + animation: none; + stroke-dasharray: 20; + stroke-dashoffset: 100; + } + + .spinner-lg .path { + stroke-dasharray: 50; + } +} + +.spinner .bg { + stroke: var(--content-default); +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + from { + stroke-dashoffset: var(--circumference); + } + to { + stroke-dashoffset: calc(var(--circumference) * -1); + } +} diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 0000000..3000d56 --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react' +import * as Sentry from '@sentry/remix' +import { startTransition, StrictMode, useEffect } from 'react' +import { hydrateRoot } from 'react-dom/client' + +if ('ENV' in window && typeof window.ENV.SENTRY_DSN === 'string') { + Sentry.init({ + dsn: window.ENV.SENTRY_DSN, + tracesSampleRate: 0.1, + enabled: process.env.NODE_ENV === 'production', + integrations: [ + new Sentry.BrowserTracing({ + routingInstrumentation: Sentry.remixRouterInstrumentation( + useEffect, + useLocation, + useMatches, + ), + }), + ], + }) +} + +startTransition(() => { + hydrateRoot( + document, + + + , + ) +}) diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..25d9c4c --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { PassThrough } from 'node:stream' +import { Response, type AppLoadContext, type EntryContext } from '@remix-run/node' +import { RemixServer } from '@remix-run/react' +import * as Sentry from '@sentry/remix' +import isbot from 'isbot' +import { renderToPipeableStream } from 'react-dom/server' + +if (typeof process.env.SENTRY_DSN === 'string') { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 0.1, + enabled: process.env.NODE_ENV === 'production', + }) +} + +const ABORT_DELAY = 5_000 + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + _loadContext: AppLoadContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext) +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true + const body = new PassThrough() + + responseHeaders.set('Content-Type', 'text/html;charset=utf8') + + resolve( + new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }), + ) + + pipe(body) + }, + onShellError(error: unknown) { + reject(error) + }, + onError(error: unknown) { + // eslint-disable-next-line no-param-reassign + responseStatusCode = 500 + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error) + } + }, + }, + ) + + setTimeout(abort, ABORT_DELAY) + }) +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true + const body = new PassThrough() + + responseHeaders.set('Content-Type', 'text/html;charset=utf8') + + resolve( + new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }), + ) + + pipe(body) + }, + onShellError(error: unknown) { + reject(error) + }, + onError(error: unknown) { + // eslint-disable-next-line no-param-reassign + responseStatusCode = 500 + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error) + } + }, + }, + ) + + setTimeout(abort, ABORT_DELAY) + }) +} diff --git a/app/hooks/use-is-overflow.ts b/app/hooks/use-is-overflow.ts new file mode 100644 index 0000000..8bcdd31 --- /dev/null +++ b/app/hooks/use-is-overflow.ts @@ -0,0 +1,71 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import throttle from 'lodash/throttle' +import { useLayoutEffect, useState, type MutableRefObject } from 'react' + +export const useIsOverflow = ( + ref: MutableRefObject, + callback?: (hasOverflow: boolean) => void, +) => { + const [isOverflow, setIsOverflow] = useState() + const [scrollStart, setScrollStart] = useState(true) + const [scrollEnd, setScrollEnd] = useState(false) + + useLayoutEffect(() => { + if (!ref?.current) return + + const trigger = () => { + if (!ref?.current) return + const { current } = ref + + const hasOverflow = current.scrollHeight > current.clientHeight + setIsOverflow(hasOverflow) + + if (callback) callback(hasOverflow) + } + + const handleScroll = throttle( + () => { + if (!ref?.current) return + const { current } = ref + + if (current.scrollTop === 0) { + setScrollStart(true) + } else { + setScrollStart(false) + } + + const offsetBottom = current.scrollHeight - current.clientHeight + if (current.scrollTop >= offsetBottom && scrollEnd === false) { + setScrollEnd(true) + } else { + setScrollEnd(false) + } + }, + 125, + { leading: true, trailing: true }, + ) + + trigger() + + const { current } = ref + current.addEventListener('scroll', handleScroll) + window.addEventListener('resize', handleScroll) + return () => { + current.removeEventListener('scroll', handleScroll) + window.removeEventListener('resize', handleScroll) + } + }, [callback, ref, scrollStart, scrollEnd]) + + return { + isOverflow, + scrollStart, + scrollEnd, + } +} diff --git a/app/hooks/use-key.ts b/app/hooks/use-key.ts new file mode 100644 index 0000000..58bef5d --- /dev/null +++ b/app/hooks/use-key.ts @@ -0,0 +1,29 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import Mousetrap from 'mousetrap' +import { useEffect } from 'react' + +type Key = Parameters[0] +type Callback = Parameters[1] + +/** + * Bind a keyboard shortcut with [Mousetrap](https://craig.is/killing/mice). + * Callback `fn` should be memoized. `key` does not need to be memoized. + */ +export const useKey = (key: Key, fn: Callback) => { + useEffect(() => { + Mousetrap.bind(key, fn) + return () => { + Mousetrap.unbind(key) + } + // JSON.stringify lets us avoid having to memoize the keys at the call site. + // Doing something similar with the callback makes less sense. + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [JSON.stringify(key), fn]) +} diff --git a/app/hooks/use-stepped-scroll.ts b/app/hooks/use-stepped-scroll.ts new file mode 100644 index 0000000..f308c1b --- /dev/null +++ b/app/hooks/use-stepped-scroll.ts @@ -0,0 +1,75 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useEffect, type RefObject } from 'react' + +// appreciate this. I suffered + +/** + * While paging through elements in a list one by one, scroll just far enough to + * the selected item in view. Outer container is the one that scrolls. The inner + * container does not scroll, it moves inside the outer container. + */ +export function useSteppedScroll( + outerContainerRef: RefObject, + innerContainerRef: RefObject, + selectedIdx: number, + itemSelector = 'li', +) { + useEffect(() => { + const outer = outerContainerRef.current + const inner = innerContainerRef.current + const outerContainerHeight = outer ? outer.clientHeight : 0 + + // rather than put refs on all the li, get item by index using the ul ref + const item = inner?.querySelectorAll(itemSelector)[selectedIdx] + if (outer && inner && item) { + // absolute top and bottom of scroll container in viewport. annoyingly, + // the div's bounding client rect bottom is the real bottom, including the + // the part that's scrolled out of view. what we want is the bottom of the + // part that's in view + const outerTop = outer.getBoundingClientRect().top + const outerBottom = outerTop + outerContainerHeight + + // absolute top and bottom of list and item in viewport. outer stays where + // it is, inner moves within it + const { top: itemTop, bottom: itemBottom } = item.getBoundingClientRect() + const { top: innerTop } = inner.getBoundingClientRect() + + // when we decide whether the item we're scrolling to is in view already + // or not, we need to compare absolute positions, i.e., is this item + // inside the visible rectangle or not + const shouldScrollUp = itemTop < outerTop + const shouldScrollDown = itemBottom > outerBottom + + // this probably the most counterintuitive part. now we're trying to tell + // the scrolling container how far to scroll, so we need the position of + // the item relative to the top of the full list (innerTop), not relative + // to the absolute y-position of the top of the visible scrollPort + // (outerTop) + const itemTopScrollTo = itemTop - innerTop - 1 + const itemBottomScrollTo = itemBottom - innerTop + + if (shouldScrollUp) { + // when scrolling up, scroll to the top of the item you're scrolling to. + // -1 is for top outline + // -32 compensates for the height of the position: sticky

    + outer.scrollTo({ top: itemTopScrollTo - 1 - 24 }) + } else if (shouldScrollDown) { + // when scrolling down, we want to scroll just far enough so the bottom + // edge of the selected item is in view. Because scrollTo is about the + // *top* edge of the scrolling container, that means we scroll to + // LIST_HEIGHT *above* the bottom edge of the item. +2 is for top *and* + // bottom outline + outer.scrollTo({ top: itemBottomScrollTo - outerContainerHeight + 2 }) + } + } + // don't depend on the refs because they get nuked on every render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedIdx, itemSelector]) +} diff --git a/app/hooks/use-window-size.ts b/app/hooks/use-window-size.ts new file mode 100644 index 0000000..6eaa6c9 --- /dev/null +++ b/app/hooks/use-window-size.ts @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useEffect, useState } from 'react' + +function useWindowSize() { + const [size, setSize] = useState<{ + width: number + height: number + }>({ + width: 0, + height: 0, + }) + + const [hasLargeScreen, setHasLargeScreen] = useState(false) + + useEffect(() => { + // Only execute all the code below in client side + if (typeof window !== 'undefined') { + // Handler to call on window resize + function handleResize() { + // Set window width/height to state + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + + setHasLargeScreen(window.innerWidth >= 800) + } + + window.addEventListener('resize', handleResize) + + handleResize() + + return () => window.removeEventListener('resize', handleResize) + } + }, []) + + return { + size, + hasLargeScreen, + } +} + +export default useWindowSize diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..0200b93 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,148 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + json, + type LinksFunction, + type LoaderArgs, + type SerializeFrom, + type V2_MetaFunction, +} from '@remix-run/node' +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useCatch, + useLoaderData, + useRouteLoaderData, +} from '@remix-run/react' +import { withSentry } from '@sentry/remix' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +import type { Author } from '~/components/rfd/RfdPreview' +import { isAuthenticated } from '~/services/authn.server' +import { + fetchRfds, + findAuthors, + findLabels, + isLocalMode, + provideNewRfdNumber, + type RfdListItem, +} from '~/services/rfd.server' +import styles from '~/tailwind.css' + +import LoadingBar from './components/LoadingBar' +import NotFound from './components/NotFound' +import { inlineCommentsCookie, themeCookie } from './services/cookies.server' + +export const meta: V2_MetaFunction = () => { + return [{ title: 'RFD / Oxide' }] +} + +export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] + +export const loader = async ({ request }: LoaderArgs) => { + let theme = (await themeCookie.parse(request.headers.get('Cookie'))) ?? 'dark-mode' + let inlineComments = + (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true + + const user = await isAuthenticated(request) + const rfds: RfdListItem[] = await fetchRfds(user) + + const authors: Author[] = rfds ? findAuthors(rfds) : [] + const labels: string[] = rfds ? findLabels(rfds) : [] + + return json({ + // Any data added to the ENV key of this loader will be injected into the + // global window object (window.ENV) + ENV: { + SENTRY_DSN: process.env.SENTRY_DSN, + }, + theme, + inlineComments, + user, + rfds, + authors, + labels, + isLocalMode, + newRfdNumber: provideNewRfdNumber([...rfds]), + }) +} + +export function useRootLoaderData() { + return useRouteLoaderData('root') as SerializeFrom +} + +// 404 Catch +export function CatchBoundary() { + const caught = useCatch() + + if (caught.status === 404) { + return + } +} + +const queryClient = new QueryClient() + +export const Layout = ({ + children, + theme, +}: { + children: React.ReactNode + theme?: string +}) => ( + + + + + + + + + + {/* Use plausible analytics only on Vercel */} + {process.env.NODE_ENV === 'production' && ( +