├── .dockerignore ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── ci.yml │ ├── licence-check.yml │ └── playwright.yml ├── .gitignore ├── .infra ├── .terraform.lock.hcl ├── README.md ├── activate.sh ├── add_signing_key.sh ├── create_cert.sh ├── gcp.tf ├── static_assets.tf ├── terraform.tfstate ├── terraform.tfstate.backup └── versions.tf ├── .licenserc.yaml ├── .prettierrc ├── LICENSE ├── README.md ├── app ├── components │ ├── AsciidocBlocks │ │ ├── Document.tsx │ │ ├── Footnotes.tsx │ │ ├── Image.tsx │ │ ├── Listing.tsx │ │ ├── Mermaid.tsx │ │ └── index.ts │ ├── ClientOnly.tsx │ ├── Container.tsx │ ├── CustomIcons.tsx │ ├── Dropdown.tsx │ ├── Header.tsx │ ├── Icon.tsx │ ├── LoadingBar.tsx │ ├── Modal.tsx │ ├── NewRfdButton.tsx │ ├── PublicBanner.tsx │ ├── Search.tsx │ ├── SelectRfdCombobox.tsx │ ├── StatusBadge.tsx │ ├── Suggested.tsx │ ├── home │ │ └── FilterDropdown.tsx │ └── rfd │ │ ├── AccessWarning.tsx │ │ ├── MoreDropdown.tsx │ │ ├── RfdDiscussionDialog.tsx │ │ ├── RfdInlineComments.tsx │ │ ├── RfdJobsMonitor.tsx │ │ ├── RfdPreview.tsx │ │ └── index.css ├── hooks │ ├── use-hydrated.ts │ ├── use-is-overflow.ts │ ├── use-key.ts │ ├── use-stepped-scroll.ts │ └── use-window-size.ts ├── root.tsx ├── routes │ ├── $slug.tsx │ ├── _index.tsx │ ├── auth.github.callback.tsx │ ├── auth.github.tsx │ ├── auth.google.callback.tsx │ ├── auth.google.tsx │ ├── local-img.$.tsx │ ├── login.tsx │ ├── logout.tsx │ ├── rfd.$slug.discussion.tsx │ ├── rfd.$slug.fetch.tsx │ ├── rfd.$slug.jobs.tsx │ ├── rfd.$slug.pdf.tsx │ ├── rfd.$slug.tsx │ ├── rfd.image.$rfd.$.tsx │ ├── search.tsx │ ├── sitemap[.]xml.tsx │ ├── user.toggle-inline-comments.tsx │ └── user.toggle-theme.tsx ├── services │ ├── authn.server.ts │ ├── cookies.server.ts │ ├── github-discussion.server.ts │ ├── redirect.server.test.ts │ ├── redirect.server.ts │ ├── rfd.local.server.ts │ ├── rfd.remote.server.ts │ ├── rfd.server.ts │ ├── session.server.ts │ ├── storage.server.test.ts │ └── storage.server.ts ├── styles │ ├── index.css │ └── lib │ │ ├── asciidoc.css │ │ ├── fonts.css │ │ ├── github-markdown.css │ │ ├── highlight.css │ │ └── loading-bar.css └── utils │ ├── array.ts │ ├── asciidoctor.tsx │ ├── classed.ts │ ├── fuzz.ts │ ├── isTruthy.ts │ ├── parseRfdNum.test.ts │ ├── parseRfdNum.ts │ ├── permission.test.ts │ ├── permission.ts │ ├── rfdApi.ts │ ├── rfdApiStrategy.ts │ ├── rfdSortOrder.server.ts │ ├── titleCase.ts │ └── trackEvent.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.mjs ├── public ├── favicon-large.png ├── favicon.png ├── favicon.svg ├── fonts │ ├── GT-America-Mono-Medium.woff │ ├── GT-America-Mono-Medium.woff2 │ ├── GT-America-Mono-Regular-OCC.woff │ ├── GT-America-Mono-Regular-OCC.woff2 │ ├── SuisseIntl-Book-WebS.woff │ ├── SuisseIntl-Book-WebS.woff2 │ ├── SuisseIntl-Light-WebS.woff │ ├── SuisseIntl-Light-WebS.woff2 │ ├── SuisseIntl-Medium-WebS.woff │ ├── SuisseIntl-Medium-WebS.woff2 │ ├── SuisseIntl-Regular-WebS.woff │ ├── SuisseIntl-Regular-WebS.woff2 │ ├── SuisseIntl-RegularItalic-WebS.woff │ └── SuisseIntl-RegularItalic-WebS.woff2 ├── img │ └── header-grid-mask.png ├── robots.txt └── svgs │ ├── caution.svg │ ├── footnote.svg │ ├── header-grid.svg │ ├── info-yellow.svg │ ├── info.svg │ ├── link.svg │ ├── tip.svg │ └── warning.svg ├── svgr.config.js ├── tailwind.config.ts ├── test ├── e2e │ └── everything.spec.ts └── vitest.config.ts ├── tsconfig.json ├── types └── asciidoctor-mathjax.d.ts ├── vercel.json ├── vite.config.ts └── vite └── local-rfd-plugin.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | /node_modules 3 | *.log 4 | .DS_Store 5 | .env 6 | /.cache 7 | /public/build 8 | /build 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 3 | "ignorePatterns": ["**/*.js", "**/**/*.js"], 4 | "rules": { 5 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 6 | "eqeqeq": "error", 7 | "no-param-reassign": "error", 8 | "no-return-assign": "error", 9 | "react-hooks/exhaustive-deps": "error", 10 | "react-hooks/rules-of-hooks": "error" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | _If you've been granted access – before submission, double-check for any sensitive or 10 | confidential info from RFDs._ 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | _If you've been granted access – before submission, double-check for any sensitive or 10 | confidential info from RFDs._ 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '18' 16 | cache: 'npm' 17 | - run: npm install 18 | - name: Check format 19 | run: npm run fmt:check 20 | - name: Typecheck 21 | run: npm run tsc 22 | - name: Lint 23 | run: npm run lint 24 | - name: Unit tests 25 | run: npm test run 26 | - name: Build 27 | run: npm run build 28 | -------------------------------------------------------------------------------- /.github/workflows/licence-check.yml: -------------------------------------------------------------------------------- 1 | # To run this check locally, install SkyWalking Eyes somehow 2 | # (https://github.com/apache/skywalking-eyes). On macOS you can `brew install 3 | # license-eye` and run `license-eye header check` or `license-eye header fix`. 4 | 5 | name: license-check 6 | 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | 12 | jobs: 13 | license: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Check License Header 18 | uses: apache/skywalking-eyes/header@5dfa68f93380a5e57259faaf95088b7f133b5778 19 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | # This is an unusual job because it's triggered by deploy events rather than 4 | # PR/push. The if condition means we only run on deployment_status events where 5 | # the status is success, i.e., we only run after Vercel deploy has succeeded. 6 | 7 | on: 8 | deployment_status: 9 | jobs: 10 | playwright: 11 | if: 12 | github.event_name == 'deployment_status' && github.event.deployment_status.state == 13 | 'success' 14 | timeout-minutes: 60 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Install Playwright Browsers 24 | run: npx playwright install --with-deps 25 | - name: Run Playwright tests 26 | run: npx playwright test 27 | env: 28 | BASE_URL: ${{ github.event.deployment_status.target_url }} 29 | - uses: actions/upload-artifact@v4 30 | if: always() 31 | with: 32 | name: test-results 33 | path: test-results/ 34 | retention-days: 30 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .terraform 3 | 4 | .cache 5 | .env 6 | .vercel 7 | .output 8 | .eslintcache 9 | 10 | /build/ 11 | /public/build/ 12 | /api/index.js 13 | /api/index.js.map 14 | /api/_assets 15 | tsconfig.tsbuildinfo 16 | test-results/ 17 | 18 | .DS_Store 19 | 20 | /app/components/icons 21 | -------------------------------------------------------------------------------- /.infra/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "4.38.0" 6 | hashes = [ 7 | "h1:YaAiiWx0bbMKk/TfLn9XIeGX4/fEQaZT7Z1Q38vTGoM=", 8 | "zh:019ee2c826fa9e503b116909d8ef95e190f7e54078a72b3057a3e1f65b2ae0c2", 9 | "zh:2895bdd0036032ca4667cddecec2372d9ba190be8ecf2527d705ef3fb3f5c2fb", 10 | "zh:6bf593e604619fb413b7869ebe72de0ff883860cfc85d58ae06eb2b7cf088a6d", 11 | "zh:72d2ca1f36062a250a6b499363e3eb4c4b983a415b7c31c5ee7dab4dbeeaf020", 12 | "zh:7971431d90ecfdf3c50027f38447628801b77d03738717d6b22fb123e27a3dfc", 13 | "zh:821be1a1f709e6ef264a98339565609f5cfeb25b32ad6af5bf4b562fde5677e8", 14 | "zh:8b3811426eefd3c47c4de2990d129c809bc838a08a18b3497312121f3a482e73", 15 | "zh:a5e3c3aad4e7873014e4773fd8c261f74abc5cf6ab417c0fce3da2ed93154451", 16 | "zh:bb026e3c79408625fe013337cf7d7608e20b2b1c7b02d38a10780e191c08e56c", 17 | "zh:defa59b317eea43360a8303440398ed02717d8f29880ffad407ca7ebb63938fd", 18 | "zh:f4883b304c54dd0480af5463b3581b01bc43d9f573cfd9179d7e4d8b6af27147", 19 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 20 | ] 21 | } 22 | 23 | provider "registry.terraform.io/hashicorp/random" { 24 | version = "3.4.3" 25 | hashes = [ 26 | "h1:xZGZf18JjMS06pFa4NErzANI98qi59SEcBsOcS2P2yQ=", 27 | "zh:41c53ba47085d8261590990f8633c8906696fa0a3c4b384ff6a7ecbf84339752", 28 | "zh:59d98081c4475f2ad77d881c4412c5129c56214892f490adf11c7e7a5a47de9b", 29 | "zh:686ad1ee40b812b9e016317e7f34c0d63ef837e084dea4a1f578f64a6314ad53", 30 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 31 | "zh:84103eae7251384c0d995f5a257c72b0096605048f757b749b7b62107a5dccb3", 32 | "zh:8ee974b110adb78c7cd18aae82b2729e5124d8f115d484215fd5199451053de5", 33 | "zh:9dd4561e3c847e45de603f17fa0c01ae14cae8c4b7b4e6423c9ef3904b308dda", 34 | "zh:bb07bb3c2c0296beba0beec629ebc6474c70732387477a65966483b5efabdbc6", 35 | "zh:e891339e96c9e5a888727b45b2e1bb3fcbdfe0fd7c5b4396e4695459b38c8cb1", 36 | "zh:ea4739860c24dfeaac6c100b2a2e357106a89d18751f7693f3c31ecf6a996f8d", 37 | "zh:f0c76ac303fd0ab59146c39bc121c5d7d86f878e9a69294e29444d4c653786f8", 38 | "zh:f143a9a5af42b38fed328a161279906759ff39ac428ebcfe55606e05e1518b93", 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.infra/README.md: -------------------------------------------------------------------------------- 1 | # RFD Image Hosting 2 | 3 | Images for the RFD frontend are stored external to the codebase, and the configuration 4 | stored here defines the GCP infrastructure used to host them. 5 | 6 | For images we want to be able to: 7 | 8 | 1. Require a user to authenticate to access an image even if they know the path to the image 9 | 2. Serve images without requiring a trip through the frontend server 10 | 11 | Generally any CDN can satisfy point 2 alone. Combining both requirements though is more 12 | difficult without requiring that the CDN understanding user authentication. The solution 13 | here does not fully satisfy the above requirements, but attempts to get close. Images served 14 | from the generated infrastructure require that the image url contain a valid signature that 15 | encodes the image being requested, how long the url is valid for, and the key used to sign 16 | the request. Once a url expires it will begin responding with a 403 error. This allows for 17 | the paths to images to be publicly known without providing generic public access. 18 | 19 | Note: Infrastructure configuration is stored in this repository until a point in time where 20 | we have RFD infrastructure that is separate from `cio`. At that point, this infrastructure 21 | should be owned by the RFD service. 22 | 23 | ### GCP Infrastructure 24 | 25 | Image storage and serving is handled by 26 | [Cloud CDN](https://cloud.google.com/cdn/docs/using-signed-urls), backed by 27 | [Cloud Storage](https://cloud.google.com/storage). 28 | 29 | ``` 30 | ┌─────────────────┐ 31 | │ Cloud CDN ├─ Cache 32 | └────────┬────────┘ 33 | ┌────────┴────────┐ 34 | │ Load Balancer │ 35 | └────────┬────────┘ 36 | ┌────────┴────────┐ 37 | │ Backend Bucket ├─ Validate signature 38 | └────────┬────────┘ 39 | ┌────────┴────────┐ 40 | │ Cloud Storage │ 41 | └─────────────────┘ 42 | ``` 43 | 44 | ### Deploy 45 | 46 | There are a few steps to deploying this infrastructure: 47 | 48 | 1. Run `create_cert.sh ` to generate a TLS certificate to attach to the load 49 | balancer. We do not create a certificate during Terraform step to prevent the private key 50 | from being written to the tfstate. 51 | https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_ssl_certificate 52 | 2. Run `terraform apply`. If you changed the name of the certificate created in 53 | `create_cert.sh` or the project to be deployed to, then ensure that the `cert` and 54 | `project` variables are specified. 55 | 3. Run `add_signing_key.sh ` to generate a signing key. 56 | This wil output the secret signing key for generating signed urls. Ensure that this key 57 | is stored securely, it can not be recovered. We do not generate this key during the 58 | Terraform step as doing so would write the key to the tfstate. 59 | https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/compute_backend_service_signed_url_key 60 | 4. Run `activate.sh ` to grant permission for Cloud CDN to read 61 | from the generated storage bucket. 62 | 5. Create a DNS record pointing `static.rfd.shared.oxide.computer` (unless you used a 63 | different domain name) to the IP address allocated by executing the Terraform config. 64 | -------------------------------------------------------------------------------- /.infra/activate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright Oxide Computer Company 7 | 8 | 9 | if [ -z "$1" ] || [ -z "$2" ] 10 | then 11 | echo "A project and storage bucket name must be supplied" 12 | exit 1 13 | fi 14 | 15 | PROJECT=$1 16 | BUCKET=$2 17 | 18 | PROJECTNUMBER=$(gcloud projects list \ 19 | --filter="$(gcloud config get-value project --project $PROJECT)" \ 20 | --format="value(PROJECT_NUMBER)" \ 21 | --project $PROJECT 22 | ) 23 | 24 | gsutil iam ch \ 25 | serviceAccount:service-$PROJECTNUMBER@cloud-cdn-fill.iam.gserviceaccount.com:objectViewer \ 26 | gs://$BUCKET 27 | -------------------------------------------------------------------------------- /.infra/add_signing_key.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright Oxide Computer Company 7 | 8 | 9 | if [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ] 10 | then 11 | echo "A project, backend bucket, and key name must be supplied" 12 | exit 1 13 | fi 14 | 15 | PROJECT=$1 16 | BACKEND=$2 17 | KEYNAME=$3 18 | 19 | # Key generation uses the recommendation from GCP 20 | # See: https://cloud.google.com/cdn/docs/using-signed-urls#configuring_signed_request_keys 21 | KEY=$(head -c 16 /dev/urandom | base64 | tr +/ -_) 22 | KEYFILE=$(head -c 16 /dev/urandom | base64 | tr +/ -_) 23 | 24 | echo $KEY > $KEYFILE 25 | 26 | gcloud compute backend-buckets \ 27 | add-signed-url-key $BACKEND \ 28 | --key-name $KEYNAME \ 29 | --key-file $KEYFILE \ 30 | --project $PROJECT 31 | 32 | 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." 33 | echo "Key: $KEY" 34 | 35 | rm $KEYFILE -------------------------------------------------------------------------------- /.infra/create_cert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This Source Code Form is subject to the terms of the Mozilla Public 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this 4 | # file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | # 6 | # Copyright Oxide Computer Company 7 | 8 | 9 | if [ -z "$1" ] 10 | then 11 | echo "A project must be supplied" 12 | exit 1 13 | fi 14 | 15 | PROJECT=$1 16 | 17 | gcloud compute ssl-certificates create rfd-static-cert \ 18 | --description="Static asset serving for RFD frontend" \ 19 | --domains="static.rfd.shared.oxide.computer" \ 20 | --global \ 21 | --project $PROJECT -------------------------------------------------------------------------------- /.infra/gcp.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = var.project 3 | region = "us-central1" 4 | zone = "us-central1-c" 5 | } 6 | 7 | variable "project" { 8 | default = "websites-326710" 9 | } 10 | 11 | variable "prefix" { 12 | default = "rfd-static-assets" 13 | } 14 | 15 | variable "cert" { 16 | default = "rfd-static-cert" 17 | } 18 | -------------------------------------------------------------------------------- /.infra/static_assets.tf: -------------------------------------------------------------------------------- 1 | # This configuration will spin up the core components for hosting the static assets needed by the 2 | # RFD frontend. It does not: 3 | # 1. Create the url signing key for the bucket backend 4 | # 2. Grant access for Cloud CDN to access objects in the storage bucket 5 | # These steps should be performed out of band to ensure that the signing key is not stored in the 6 | # terraform state, and that the Cloud CDN does not have access until a signing key is set 7 | 8 | # These resources must be created prior to deployment and their state must not be stored as they 9 | # hold private data 10 | data "google_compute_ssl_certificate" "rfd_static_cert" { 11 | name = var.cert 12 | } 13 | 14 | # A random suffix to avoid colliding bucket names 15 | resource "random_id" "bucket_suffix" { 16 | byte_length = 8 17 | } 18 | 19 | # The storage bucket for holding static assets used by the frontend 20 | resource "google_storage_bucket" "storage_bucket" { 21 | name = "${var.prefix}-${random_id.bucket_suffix.hex}" 22 | location = "us-east1" 23 | uniform_bucket_level_access = true 24 | storage_class = "STANDARD" 25 | force_destroy = true 26 | } 27 | 28 | # A fixed ip address that is assigned to the load CDN loadbalancer 29 | resource "google_compute_global_address" "ip_address" { 30 | name = "${var.prefix}-ip" 31 | } 32 | 33 | # Backend service for serving static assets from the bucket to the load balancer 34 | resource "google_compute_backend_bucket" "backend" { 35 | name = "${var.prefix}-backend" 36 | description = "Serves static assets for the RFD frontend" 37 | bucket_name = google_storage_bucket.storage_bucket.name 38 | enable_cdn = true 39 | 40 | cdn_policy { 41 | cache_mode = "CACHE_ALL_STATIC" 42 | client_ttl = 30 43 | default_ttl = 30 44 | max_ttl = 60 45 | negative_caching = false 46 | serve_while_stale = 0 47 | } 48 | } 49 | 50 | # Frontend load balancer 51 | resource "google_compute_url_map" "url_map" { 52 | name = "${var.prefix}-http-lb" 53 | default_service = google_compute_backend_bucket.backend.id 54 | } 55 | 56 | # Route to the load balancer 57 | resource "google_compute_target_https_proxy" "proxy" { 58 | name = "${var.prefix}-https-lb-proxy" 59 | url_map = google_compute_url_map.url_map.id 60 | ssl_certificates = [data.google_compute_ssl_certificate.rfd_static_cert.id] 61 | } 62 | 63 | # Rule to forward all traffic on the external ip address to the RFD static asset backend 64 | resource "google_compute_global_forwarding_rule" "forwarding" { 65 | name = "${var.prefix}-https-lb-forwarding-rule" 66 | ip_protocol = "TCP" 67 | load_balancing_scheme = "EXTERNAL_MANAGED" 68 | port_range = "443" 69 | target = google_compute_target_https_proxy.proxy.id 70 | ip_address = google_compute_global_address.ip_address.id 71 | } 72 | -------------------------------------------------------------------------------- /.infra/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | google = { 4 | source = "hashicorp/google" 5 | } 6 | } 7 | required_version = ">= 1.2.8" 8 | } 9 | -------------------------------------------------------------------------------- /.licenserc.yaml: -------------------------------------------------------------------------------- 1 | header: 2 | # default is 80, need to make it slightly longer for a long shebang 3 | license-location-threshold: 100 4 | license: 5 | spdx-id: MPL-2.0 6 | content: | 7 | This Source Code Form is subject to the terms of the Mozilla Public 8 | License, v. 2.0. If a copy of the MPL was not distributed with this 9 | file, you can obtain one at https://mozilla.org/MPL/2.0/. 10 | 11 | Copyright Oxide Computer Company 12 | 13 | paths: 14 | - '**/*.{ts,tsx,css,html,js,sh}' 15 | 16 | comment: on-failure 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 92, 3 | "proseWrap": "always", 4 | "semi": false, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "plugins": ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], 8 | "importOrder": ["", "", "^~/(.*)$", "", "^[./]"], 9 | "importOrderTypeScriptVersion": "5.2.2" 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RFD Site 2 | 3 | ## Table of Contents 4 | 5 | - [Introduction](#rfd-site) 6 | - [Technology](#the-technology) 7 | - [Contributing](#contributing) 8 | - [Setup](#setup) 9 | - [Running](#running) 10 | - [Running Locally](#running-locally) 11 | - [Write RFDs Locally](#write-rfds-locally) 12 | - [License](#license) 13 | 14 | ## Introduction 15 | 16 | At Oxide, RFDs (Requests for Discussion) play a crucial role in driving our architectural 17 | and design decisions. They document the processes, APIs, and tools that we use. To learn 18 | more about the RFD process, you can read 19 | [RFD 1: Requests for Discussion](https://rfd.shared.oxide.computer/rfd/0001). 20 | 21 | This repo represents the web frontend for browsing, searching, and reading RFDs, not to be 22 | confused with [`oxidecomputer/rfd`](https://github.com/oxidecomputer/rfd), a private repo 23 | that houses RFD content and discussion, or 24 | [`oxidecomputer/rfd-api`](https://github.com/oxidecomputer/rfd-api), the backend that serves 25 | RFD content to this site and gives us granular per-user control over RFD access. You can 26 | read more about this site and how we use it in our blog post 27 | [A Tool for Discussion](https://oxide.computer/blog/a-tool-for-discussion). 28 | 29 | ## Technology 30 | 31 | The site is built with [Remix](https://remix.run/), a full stack React web framework. 32 | [rfd-api](https://github.com/oxidecomputer/rfd-api) collects the RFDs from 33 | `oxidecomputer/rfd`, stores them in a database, and serves them through an HTTP API, which 34 | this site uses. RFD discussions come from an associated pull request on GitHub. These are 35 | linked to from the document and displayed inline alongside the text. 36 | 37 | Documents are rendered with 38 | [react-asciidoc](https://github.com/oxidecomputer/react-asciidoc), a work-in-progress React 39 | AsciiDoc renderer we've created, built on top of 40 | [`asciidoctor.js`](https://github.com/asciidoctor/asciidoctor.js). 41 | 42 | ## Deploying 43 | 44 | Our site is hosted on Vercel and this repo uses the Vercel adapter, but Remix can be 45 | deployed to [any JS runtime](https://remix.run/docs/en/main/discussion/runtimes). 46 | 47 | ## Contributing 48 | 49 | This repo is public because others are interested in the RFD process and the tooling we've 50 | built around it. In its present state, it's the code we're using on our 51 | [deployed site](https://rfd.shared.oxide.computer/) and is tightly coupled to us and our 52 | [design system](https://github.com/oxidecomputer/design-system). We're open to PRs that 53 | improve this site, especially if they make the repo easier for others to use and contribute 54 | to. However, we are a small company, and the primary goal of this repo is as an internal 55 | tool for Oxide, so we can't guarantee that PRs will be integrated. 56 | 57 | ## Running 58 | 59 | ### Setup 60 | 61 | `npm` v7 or higher is recommended due to 62 | [`lockfileVersion: 2`](https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json#lockfileversion) 63 | in `package-lock.json`. 64 | 65 | ```sh 66 | npm install 67 | ``` 68 | 69 | ### Running Locally 70 | 71 | ```sh 72 | npm run dev 73 | ``` 74 | 75 | and go to [http://localhost:3000](http://localhost:3000). The site will live-reload on file 76 | changes. The site should work with local RFDs (without search) without having to set any env 77 | vars. See below on how to set up local RFD preview. 78 | 79 | ### Write RFDs Locally 80 | 81 | To preview an RFD you're working on in the site, use the `LOCAL_RFD_REPO` env var to tell 82 | the site to pull content from your local clone of the `rfd` repo instead of the API. No 83 | other env vars (such as the ones that let you talk to CIO) are required. For example: 84 | 85 | ```sh 86 | LOCAL_RFD_REPO=~/oxide/rfd npm run dev 87 | ``` 88 | 89 | Then go to `localhost:3000/rfd/0123` as normal. When you edit the file in the other repo, 90 | the page will reload automatically. The index also works in local mode: it lists all RFDs it 91 | can see locally. 92 | 93 | Note that this does not pull RFDs from all branches like the production site does. It simply 94 | reads files from the specified directory, so it will only have access to files on the 95 | current branch. Missing RFDs will 404. If you are working on two RFDs and they're on 96 | different branches, you cannot preview both at the same time unless you make a temporary 97 | combined branch that contains both. 98 | 99 | ### Configuration 100 | 101 | When running in a non-local mode, the following settings must be specified: 102 | 103 | - `SESSION_SECRET` - Key that will be used to signed cookies 104 | 105 | - `RFD_API` - Backend RFD API to communicate with (i.e. https://api.server.com) 106 | - `RFD_API_CLIENT_ID` - OAuth client id create via the RFD API 107 | - `RFD_API_CLIENT_SECRET` - OAuth client secret create via the RFD API 108 | - `RFD_API_GOOGLE_CALLBACK_URL` - Should be of the form of 109 | `https://{rfd_site_hostname}/auth/google/callback` 110 | - `RFD_API_GITHUB_CALLBACK_URL` - Should be of the form of 111 | `https://{rfd_site_hostname}/auth/github/callback` 112 | 113 | - `STORAGE_URL` - Url of bucket for static assets 114 | - `STORAGE_KEY_NAME` - Name of the key defined in `STORAGE_KEY` 115 | - `STORAGE_KEY` - Key for generating signed static asset urls 116 | 117 | - `GITHUB_APP_ID` - App id for fetching GitHub PR discussions 118 | - `GITHUB_INSTALLATION_ID` - Installation id of GitHub App 119 | - `GITHUB_PRIVATE_KEY` - Private key of the GitHub app for discussion fetching 120 | 121 | ## License 122 | 123 | Unless otherwise noted, all components are licensed under the 124 | [Mozilla Public License Version 2.0](LICENSE). 125 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/Document.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Content, type DocumentBlock } from '@oxide/react-asciidoc' 9 | 10 | const CustomDocument = ({ document }: { document: DocumentBlock }) => ( 11 |
15 | 16 |
17 | ) 18 | 19 | export { CustomDocument } 20 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/Footnotes.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type DocumentBlock } from '@oxide/react-asciidoc' 9 | import { Link } from '@remix-run/react' 10 | 11 | import Container from '../Container' 12 | import { GotoIcon } from '../CustomIcons' 13 | 14 | const Footnotes = ({ doc }: { doc: DocumentBlock }) => { 15 | if (!doc.footnotes) return null 16 | 17 | if (doc.footnotes.length > 0 && doc.blocks && !doc.attributes['nofootnotes']) { 18 | return ( 19 |
20 | 21 |
22 | Footnotes 23 |
24 | 25 |
    29 | {doc.footnotes.map((footnote) => ( 30 |
  • 35 |
    36 | {footnote.index} 37 |
    38 |
    39 |

    {' '} 43 | 47 | 48 | 49 | View 50 | 51 | 52 |

    53 |
  • 54 | ))} 55 |
56 |
57 |
58 | ) 59 | } else { 60 | return null 61 | } 62 | } 63 | 64 | export default Footnotes 65 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/Image.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import * as Ariakit from '@ariakit/react' 10 | import { type Block, type Inline } from '@asciidoctor/core' 11 | import { Title, useConverterContext, type ImageBlock } from '@oxide/react-asciidoc' 12 | import { useState } from 'react' 13 | 14 | function nodeIsInline(node: Block | Inline): node is Inline { 15 | return node.isInline() 16 | } 17 | 18 | const InlineImage = ({ node }: { node: Block | Inline }) => { 19 | const documentAttrs = node.getDocument().getAttributes() 20 | 21 | let target = '' 22 | if (nodeIsInline(node)) { 23 | target = node.getTarget() || '' // Getting target on inline nodes 24 | } else { 25 | target = node.getAttribute('target') // Getting target on block nodes 26 | } 27 | 28 | let uri = node.getImageUri(target) 29 | let url = '' 30 | 31 | url = `/rfd/image/${documentAttrs.rfdnumber}/${uri}` 32 | 33 | let img = ( 34 | {node.getAttribute('alt')} 40 | ) 41 | 42 | if (node.hasAttribute('link')) { 43 | img = ( 44 | 45 | {img} 46 | 47 | ) 48 | } 49 | 50 | return ( 51 |
58 | {img} 59 |
60 | ) 61 | } 62 | 63 | const Image = ({ node }: { node: ImageBlock }) => { 64 | const { document } = useConverterContext() 65 | const docAttrs = document.attributes || {} 66 | 67 | let url = '' 68 | 69 | const [lightboxOpen, setLightboxOpen] = useState(false) 70 | 71 | url = `/rfd/image/${docAttrs.rfdnumber}/${node.imageUri}` 72 | 73 | let img = ( 74 | {node.attributes['alt'].toString()} 81 | ) 82 | 83 | if (node.attributes['link']) { 84 | img = ( 85 | 86 | {img} 87 | 88 | ) 89 | } 90 | 91 | return ( 92 | <> 93 |
setLightboxOpen(true)} 100 | > 101 |
{img}
102 | 103 | </div> 104 | <Ariakit.Dialog 105 | open={lightboxOpen} 106 | onClose={() => setLightboxOpen(false)} 107 | className="fixed [&_img]:mx-auto" 108 | backdrop={<div className="backdrop" />} 109 | > 110 | <Ariakit.DialogDismiss className="fixed left-1/2 top-1/2 flex h-full w-full -translate-x-1/2 -translate-y-1/2 cursor-zoom-out p-20"> 111 | <img 112 | src={url} 113 | className={`max-h-full max-w-full rounded object-contain`} 114 | alt={node.attributes['alt'].toString()} 115 | /> 116 | </Ariakit.DialogDismiss> 117 | </Ariakit.Dialog> 118 | </> 119 | ) 120 | } 121 | 122 | export { Image, InlineImage } 123 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/Listing.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { Title, useConverterContext, type LiteralBlock } from '@oxide/react-asciidoc' 9 | import cn from 'classnames' 10 | 11 | import Mermaid from './Mermaid' 12 | 13 | const Listing = ({ node }: { node: LiteralBlock }) => { 14 | const { document } = useConverterContext() 15 | 16 | const docAttrs = document.attributes || {} 17 | const nowrap = node.attributes.nowrap || docAttrs['prewrap'] === undefined 18 | 19 | if (node.style === 'source') { 20 | const lang = node.language 21 | 22 | return ( 23 | <div 24 | className="listingblock" 25 | {...(node.lineNumber ? { 'data-lineno': node.lineNumber } : {})} 26 | > 27 | <Title text={node.title} /> 28 | <div className="content"> 29 | <pre className={cn('highlight', nowrap ? ' nowrap' : '')}> 30 | {lang && lang === 'mermaid' ? ( 31 | <Mermaid content={node.source || ''} /> 32 | ) : ( 33 | <code 34 | className={lang && `language-${lang}`} 35 | data-lang={lang || undefined} 36 | dangerouslySetInnerHTML={{ 37 | __html: node.content || '', 38 | }} 39 | /> 40 | )} 41 | </pre> 42 | </div> 43 | </div> 44 | ) 45 | } else { 46 | // Regular listing blocks are wrapped only in a `pre` tag 47 | return ( 48 | <div 49 | className="listingblock" 50 | {...(node.lineNumber ? { 'data-lineno': node.lineNumber } : {})} 51 | > 52 | <Title text={node.title} /> 53 | <div className="content"> 54 | <pre 55 | className={cn('highlight !block', nowrap ? 'nowrap' : '')} 56 | dangerouslySetInnerHTML={{ 57 | __html: node.content || '', 58 | }} 59 | /> 60 | </div> 61 | </div> 62 | ) 63 | } 64 | } 65 | 66 | export default Listing 67 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/Mermaid.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import mermaid from 'mermaid' 10 | import { memo, useId, useState } from 'react' 11 | 12 | mermaid.initialize({ 13 | startOnLoad: false, 14 | theme: 'dark', 15 | fontFamily: 'SuisseIntl, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif', 16 | }) 17 | 18 | const Mermaid = memo(function Mermaid({ content }: { content: string }) { 19 | const [showSource, setShowSource] = useState(false) 20 | const id = `mermaid-diagram-${useId().replace(/:/g, '_')}` 21 | 22 | const mermaidRef = async (node: HTMLElement | null) => { 23 | if (node) { 24 | const { svg } = await mermaid.render(id, content) 25 | node.innerHTML = svg 26 | } 27 | } 28 | 29 | return ( 30 | <> 31 | <button 32 | className="absolute right-2 top-2 text-mono-xs text-tertiary" 33 | onClick={() => setShowSource(!showSource)} 34 | > 35 | {showSource ? 'Hide' : 'Show'} Source <span className="text-quaternary">|</span>{' '} 36 | Mermaid 37 | </button> 38 | {!showSource ? <code ref={mermaidRef} className="w-full" /> : <code>{content}</code>} 39 | </> 40 | ) 41 | }) 42 | 43 | export default Mermaid 44 | -------------------------------------------------------------------------------- /app/components/AsciidocBlocks/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { AsciiDocBlocks } from '@oxide/design-system/components/dist' 10 | import { type Options } from '@oxide/react-asciidoc' 11 | 12 | import { CustomDocument } from './Document' 13 | import { Image } from './Image' 14 | import Listing from './Listing' 15 | 16 | export const opts: Options = { 17 | overrides: { 18 | admonition: AsciiDocBlocks.Admonition, 19 | table: AsciiDocBlocks.Table, 20 | image: Image, 21 | section: AsciiDocBlocks.Section, 22 | listing: Listing, 23 | }, 24 | customDocument: CustomDocument, 25 | } 26 | -------------------------------------------------------------------------------- /app/components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useHydrated } from '~/hooks/use-hydrated' 10 | 11 | type Props = { 12 | /** 13 | * You are encouraged to add a fallback that is the same dimensions 14 | * as the client rendered children. This will avoid content layout 15 | * shift which is disgusting 16 | */ 17 | children(): React.ReactNode 18 | fallback?: React.ReactNode 19 | } 20 | 21 | /** 22 | * Render the children only after the JS has loaded client-side. Use an optional 23 | * fallback component if the JS is not yet loaded. 24 | * 25 | * Example: Render a Chart component if JS loads, renders a simple FakeChart 26 | * component server-side or if there is no JS. The FakeChart can have only the 27 | * UI without the behavior or be a loading spinner or skeleton. 28 | * ```tsx 29 | * return ( 30 | * <ClientOnly fallback={<FakeChart />}> 31 | * {() => <Chart />} 32 | * </ClientOnly> 33 | * ); 34 | * ``` 35 | */ 36 | export function ClientOnly({ children, fallback = null }: Props) { 37 | return useHydrated() ? <>{children()}</> : <>{fallback}</> 38 | } 39 | -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import cn from 'classnames/dedupe' 10 | 11 | const Container = ({ 12 | className, 13 | wrapperClassName, 14 | children, 15 | isGrid, 16 | }: { 17 | className?: string 18 | wrapperClassName?: string 19 | children: React.ReactNode 20 | isGrid?: boolean 21 | }) => ( 22 | <div className={cn('w-full px-5 600:px-10', wrapperClassName)}> 23 | <div 24 | className={cn( 25 | 'm-auto max-w-1200', 26 | className, 27 | isGrid ? 'grid grid-cols-12 gap-4 600:gap-6' : '', 28 | )} 29 | > 30 | {children} 31 | </div> 32 | </div> 33 | ) 34 | 35 | export default Container 36 | -------------------------------------------------------------------------------- /app/components/CustomIcons.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | export const SortArrowTop = ({ className }: { className?: string }) => ( 10 | <svg 11 | width="6" 12 | height="5" 13 | viewBox="0 0 6 5" 14 | fill="none" 15 | xmlns="http://www.w3.org/2000/svg" 16 | className={className} 17 | > 18 | <path 19 | d="M2.67844 0.182052C2.82409 -0.0607001 3.17591 -0.0607005 3.32156 0.182052L5.65924 4.07818C5.80921 4.32813 5.62916 4.64612 5.33768 4.64612L0.66232 4.64612C0.370835 4.64612 0.190792 4.32813 0.34076 4.07818L2.67844 0.182052Z" 20 | fill="currentColor" 21 | /> 22 | </svg> 23 | ) 24 | 25 | export const SortArrowBottom = ({ className }: { className?: string }) => ( 26 | <svg 27 | width="6" 28 | height="5" 29 | viewBox="0 0 6 5" 30 | fill="none" 31 | xmlns="http://www.w3.org/2000/svg" 32 | className={className} 33 | > 34 | <path 35 | d="M3.32156 4.46407C3.17591 4.70682 2.82409 4.70682 2.67844 4.46407L0.340763 0.567936C0.190795 0.31799 0.370837 3.67594e-09 0.662322 1.64172e-08L5.33768 2.20783e-07C5.62917 2.33525e-07 5.80921 0.31799 5.65924 0.567936L3.32156 4.46407Z" 36 | fill="currentColor" 37 | /> 38 | </svg> 39 | ) 40 | 41 | export const GotoIcon = ({ className }: { className?: string }) => ( 42 | <svg 43 | width="12" 44 | height="12" 45 | viewBox="0 0 12 12" 46 | fill="none" 47 | xmlns="http://www.w3.org/2000/svg" 48 | className={className} 49 | > 50 | <path 51 | fillRule="evenodd" 52 | clipRule="evenodd" 53 | d="M7.53033 2.46967C7.23744 2.17678 6.76256 2.17678 6.46967 2.46967C6.17678 2.76256 6.17678 3.23744 6.46967 3.53033L8.18934 5.25H2C1.58579 5.25 1.25 5.58579 1.25 6V9C1.25 9.41421 1.58579 9.75 2 9.75C2.41421 9.75 2.75 9.41421 2.75 9V6.75H8.18934L6.46967 8.46967C6.17678 8.76256 6.17678 9.23744 6.46967 9.53033C6.76256 9.82322 7.23744 9.82322 7.53033 9.53033L10.5303 6.53033C10.8232 6.23744 10.8232 5.76256 10.5303 5.46967L7.53033 2.46967Z" 54 | fill="currentColor" 55 | /> 56 | </svg> 57 | ) 58 | -------------------------------------------------------------------------------- /app/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import * as Dropdown from '@radix-ui/react-dropdown-menu' 10 | import cn from 'classnames' 11 | import type { ReactNode } from 'react' 12 | 13 | import Icon from '~/components/Icon' 14 | 15 | export const dropdownOuterStyles = 16 | 'menu-item relative text-sans-md text-default border-b border-secondary cursor-pointer' 17 | 18 | export const dropdownInnerStyles = `focus:outline-0 focus:bg-hover px-3 py-2 pr-6` 19 | 20 | export const DropdownItem = ({ 21 | children, 22 | classNames, 23 | onSelect, 24 | }: { 25 | children: ReactNode | string 26 | classNames?: string 27 | onSelect?: () => void 28 | }) => ( 29 | <Dropdown.Item 30 | onSelect={onSelect} 31 | className={cn( 32 | dropdownOuterStyles, 33 | classNames, 34 | dropdownInnerStyles, 35 | !onSelect && 'cursor-default', 36 | )} 37 | disabled={!onSelect} 38 | > 39 | {children} 40 | </Dropdown.Item> 41 | ) 42 | 43 | export const DropdownSubTrigger = ({ 44 | children, 45 | classNames, 46 | }: { 47 | children: JSX.Element | string 48 | classNames?: string 49 | }) => ( 50 | <Dropdown.SubTrigger className={cn(dropdownOuterStyles, classNames, dropdownInnerStyles)}> 51 | {children} 52 | <Icon 53 | name="carat-down" 54 | size={12} 55 | className="absolute right-3 top-1/2 -translate-y-1/2 -rotate-90 text-tertiary" 56 | /> 57 | </Dropdown.SubTrigger> 58 | ) 59 | 60 | export const DropdownLink = ({ 61 | children, 62 | classNames, 63 | internal = false, 64 | to, 65 | disabled = false, 66 | }: { 67 | children: React.ReactNode 68 | classNames?: string 69 | internal?: boolean 70 | to: string 71 | disabled?: boolean 72 | }) => ( 73 | <a 74 | {...(internal ? {} : { target: '_blank', rel: 'noreferrer' })} 75 | href={to} 76 | className={cn( 77 | 'block ', 78 | dropdownOuterStyles, 79 | classNames, 80 | disabled && 'pointer-events-none', 81 | )} 82 | > 83 | <Dropdown.Item className={cn(dropdownInnerStyles, disabled && 'opacity-40')}> 84 | {children} 85 | </Dropdown.Item> 86 | </a> 87 | ) 88 | 89 | export const DropdownMenu = ({ 90 | children, 91 | classNames, 92 | align = 'end', 93 | }: { 94 | children: React.ReactNode 95 | classNames?: string 96 | align?: 'end' | 'start' | 'center' | undefined 97 | }) => ( 98 | <Dropdown.Portal> 99 | <Dropdown.Content 100 | className={cn( 101 | 'menu overlay-shadow z-30 mt-2 min-w-[12rem] rounded border bg-raise border-secondary [&>*:last-child]:border-b-0', 102 | classNames, 103 | )} 104 | align={align} 105 | > 106 | {children} 107 | </Dropdown.Content> 108 | </Dropdown.Portal> 109 | ) 110 | 111 | export const DropdownSubMenu = ({ 112 | children, 113 | classNames, 114 | }: { 115 | children: JSX.Element[] 116 | classNames?: string 117 | }) => ( 118 | <Dropdown.Portal> 119 | <Dropdown.SubContent 120 | className={cn( 121 | 'menu overlay-shadow z-10 ml-2 max-h-[30vh] min-w-[12rem] overflow-y-auto rounded border bg-raise border-secondary [&>*:last-child]:border-b-0', 122 | classNames, 123 | )} 124 | > 125 | {children} 126 | </Dropdown.SubContent> 127 | </Dropdown.Portal> 128 | ) 129 | -------------------------------------------------------------------------------- /app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { buttonStyle } from '@oxide/design-system' 10 | import * as Dropdown from '@radix-ui/react-dropdown-menu' 11 | import { Link, useFetcher } from '@remix-run/react' 12 | import { useCallback, useState } from 'react' 13 | 14 | import Icon from '~/components/Icon' 15 | import NewRfdButton from '~/components/NewRfdButton' 16 | import { useKey } from '~/hooks/use-key' 17 | import { useRootLoaderData } from '~/root' 18 | import type { RfdItem, RfdListItem } from '~/services/rfd.server' 19 | 20 | import { DropdownItem, DropdownMenu } from './Dropdown' 21 | import { PublicBanner } from './PublicBanner' 22 | import Search from './Search' 23 | import SelectRfdCombobox from './SelectRfdCombobox' 24 | 25 | export type SmallRfdItems = { 26 | [key: number]: RfdListItem 27 | } 28 | 29 | export default function Header({ currentRfd }: { currentRfd?: RfdItem }) { 30 | const { user, rfds, localMode, inlineComments } = useRootLoaderData() 31 | 32 | const fetcher = useFetcher() 33 | 34 | const toggleTheme = () => { 35 | fetcher.submit({}, { method: 'post', action: '/user/toggle-theme' }) 36 | } 37 | 38 | const toggleInlineComments = () => { 39 | fetcher.submit({}, { method: 'post', action: '/user/toggle-inline-comments' }) 40 | } 41 | 42 | const logout = () => { 43 | fetcher.submit({}, { method: 'post', action: '/logout' }) 44 | } 45 | 46 | const returnTo = currentRfd ? `/rfd/${currentRfd.formattedNumber}` : '/' 47 | 48 | const [open, setOpen] = useState(false) 49 | 50 | // memoized to avoid render churn in useKey 51 | const toggleSearchMenu = useCallback(() => { 52 | setOpen(!open) 53 | return false // Returning false prevents default behaviour in Firefox 54 | }, [open]) 55 | 56 | useKey('mod+k', toggleSearchMenu) 57 | 58 | return ( 59 | <div className="sticky top-0 z-20"> 60 | {!user && <PublicBanner />} 61 | <header className="flex h-14 items-center justify-between border-b px-3 bg-default border-secondary print:hidden"> 62 | <div className="flex space-x-3"> 63 | <Link 64 | to="/" 65 | prefetch="intent" 66 | className="flex h-8 w-8 items-center justify-center rounded border text-tertiary bg-secondary border-secondary elevation-1 hover:bg-hover" 67 | aria-label="Back to index" 68 | > 69 | <Icon name="logs" size={16} /> 70 | </Link> 71 | <SelectRfdCombobox isLoggedIn={!!user} currentRfd={currentRfd} rfds={rfds} /> 72 | </div> 73 | 74 | <div className="flex space-x-2"> 75 | <button 76 | className="flex h-8 w-8 items-center justify-center rounded border text-tertiary bg-secondary border-secondary elevation-1 hover:bg-hover" 77 | onClick={toggleSearchMenu} 78 | > 79 | <Icon name="search" size={16} /> 80 | </button> 81 | <Search open={open} onClose={() => setOpen(false)} /> 82 | <NewRfdButton /> 83 | 84 | {user ? ( 85 | <Dropdown.Root modal={false}> 86 | <Dropdown.Trigger className="flex h-8 w-8 items-center justify-center rounded border text-tertiary bg-secondary border-secondary elevation-1 hover:bg-hover 600:w-auto 600:px-3"> 87 | <Icon name="profile" size={16} className="flex-shrink-0" /> 88 | <span className="ml-2 hidden text-sans-sm text-default 600:block"> 89 | {user.displayName || user.email} 90 | </span> 91 | </Dropdown.Trigger> 92 | 93 | <DropdownMenu> 94 | <DropdownItem onSelect={toggleTheme}>Toggle theme</DropdownItem> 95 | <DropdownItem onSelect={toggleInlineComments}> 96 | {inlineComments ? 'Hide' : 'Show'} inline comments 97 | </DropdownItem> 98 | {localMode ? <></> : <DropdownItem onSelect={logout}>Log out</DropdownItem>} 99 | </DropdownMenu> 100 | </Dropdown.Root> 101 | ) : ( 102 | <Link 103 | to={`/login?returnTo=${returnTo}`} 104 | className={buttonStyle({ size: 'sm' })} 105 | > 106 | Sign in 107 | </Link> 108 | )} 109 | </div> 110 | </header> 111 | </div> 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /app/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { type Icon as IconType } from '@oxide/design-system/icons' 10 | 11 | import sprite from '../../node_modules/@oxide/design-system/icons/sprite.svg' 12 | 13 | type IconProps = IconType & { 14 | className?: string 15 | height?: number 16 | } 17 | 18 | const Icon = ({ name, size, ...props }: IconProps) => { 19 | const id = `${name}-${size}` 20 | 21 | return ( 22 | <svg width={size} height={size} {...props}> 23 | <use href={`${sprite}#${id}`} /> 24 | </svg> 25 | ) 26 | } 27 | 28 | export default Icon 29 | -------------------------------------------------------------------------------- /app/components/LoadingBar.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useNavigation } from '@remix-run/react' 10 | import { useEffect, useRef } from 'react' 11 | 12 | const LOADING_BAR_DELAY_MS = 20 13 | 14 | /** 15 | * Loading bar for top-level navigations. When a nav first starts, the bar zooms 16 | * from 0 to A quickly and then more slowly grows from A to B. The idea is that 17 | * the actual fetching should almost always complete while the bar is between A 18 | * and B. The animation from 0 to A to B is represented by the `loading` label. 19 | * Then once we're done fetching, we switch to the `done` animation from B to 20 | * 100. 21 | * 22 | * **Important:** we only do any of this if the navigation takes longer than 23 | * `LOADING_BAR_DELAY_MS`. This prevents us from showing the loading bar on navs 24 | * that are instantaneous, like opening a create form. Sometimes normal page 25 | * navs are also instantaneous due to caching. 26 | * 27 | * ``` 28 | * ├──────────┼──────────┼──────────┤ 29 | * 0 A B 100 30 | * 31 | * └─────────┰──────────┘ └────┰────┘ 32 | * loading done 33 | * ``` 34 | */ 35 | function LoadingBar() { 36 | const navigation = useNavigation() 37 | 38 | // use a ref because there's no need to bring React state into this 39 | const barRef = useRef<HTMLDivElement>(null) 40 | 41 | // only used for checking the loading state from inside the timeout callback 42 | const loadingRef = useRef(false) 43 | loadingRef.current = navigation.state === 'loading' 44 | 45 | useEffect(() => { 46 | const loading = navigation.state === 'loading' 47 | if (barRef.current) { 48 | if (loading) { 49 | // instead of adding the `loading` class right when loading starts, set 50 | // a LOADING_BAR_DELAY_MS timeout that starts the animation, but ONLY if 51 | // we are still loading when the callback runs. If the loaders in a 52 | // particular nav finish immediately, the value of `loadingRef.current` 53 | // will be back to `false` by the time the callback runs, skipping the 54 | // animation sequence entirely. 55 | const timeout = setTimeout(() => { 56 | if (loadingRef.current) { 57 | // Remove class and force reflow. Without this, the animation does 58 | // not restart from the beginning if we nav again while already 59 | // loading. https://gist.github.com/paulirish/5d52fb081b3570c81e3a 60 | // 61 | // It's important that this happen inside the timeout and inside the 62 | // condition for the case where we're doing an instant nav while a 63 | // nav animation is already running. If we did this outside the 64 | // timeout callback or even inside the callback but outside the 65 | // condition, we'd immediately kill an in-progress loading animation 66 | // that was about to finish on its own anyway. 67 | barRef.current?.classList.remove('loading', 'done') 68 | 69 | // Kick off the animation 70 | barRef.current?.classList.add('loading') 71 | } 72 | }, LOADING_BAR_DELAY_MS) 73 | 74 | // Clean up the timeout if we get another render in the meantime. This 75 | // doesn't seem to affect behavior but it's the Correct thing to do. 76 | return () => clearTimeout(timeout) 77 | } else if (barRef.current.classList.contains('loading')) { 78 | // Needs the if condition because if loading is false and we *don't* 79 | // have the `loading` animation running, we're on initial pageload and 80 | // we don't want to run the done animation. This is also necessary for 81 | // the case where we want to skip the animation entirely because the 82 | // loaders finished very quickly: when we get here, the callback that 83 | // sets the loading class will not have run yet, so we will not apply 84 | // the done class, which is correct because we don't want to run the 85 | // `done` animation if the `loading` animation hasn't happened. 86 | 87 | barRef.current.classList.replace('loading', 'done') 88 | 89 | // We don't need to remove `done` when it's over done because the final 90 | // state has opacity 0, and whenever a new animation starts, we remove 91 | // `done` to start fresh. 92 | } 93 | } 94 | // It is essential that we have `navigation` here as a dep rather than 95 | // calculating `loading` outside and using that as the dep. If we do the 96 | // latter, this effect does not run when a new nav happens while we're 97 | // already loading, because the value of `loading` does not change in that 98 | // case. The value of `navigation` does change on each new nav. 99 | }, [navigation]) 100 | 101 | return ( 102 | <div className="fixed left-0 right-0 top-0 z-50"> 103 | <div ref={barRef} className="global-loading-bar h-px bg-accent" /> 104 | </div> 105 | ) 106 | } 107 | 108 | export default LoadingBar 109 | -------------------------------------------------------------------------------- /app/components/Modal.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Dialog, DialogDismiss, type DialogStore } from '@ariakit/react' 10 | import cn from 'classnames' 11 | 12 | import Icon from '~/components/Icon' 13 | 14 | type Width = 'medium' | 'wide' 15 | 16 | const widthClass: Record<Width, string> = { 17 | medium: 'max-w-[32rem]', 18 | wide: 'max-w-[48rem]', 19 | } 20 | 21 | const Modal = ({ 22 | dialogStore, 23 | title, 24 | children, 25 | width = 'medium', 26 | }: { 27 | dialogStore: DialogStore 28 | title: string 29 | children: React.ReactElement 30 | width?: Width 31 | }) => { 32 | return ( 33 | <> 34 | <Dialog 35 | store={dialogStore} 36 | className={cn( 37 | 'fixed left-1/2 top-[min(50%,500px)] z-30 flex max-h-[min(800px,80vh)] w-[calc(100%-2.5rem)] max-w-[32rem] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border p-0 bg-raise border-secondary elevation-3', 38 | widthClass[width], 39 | )} 40 | backdrop={<div className="backdrop" />} 41 | > 42 | <div className="flex w-full items-center border-b p-4 bg-secondary border-secondary"> 43 | <div className="text-semi-lg text-raise">{title}</div> 44 | <DialogDismiss className="absolute right-2 top-2.5 flex rounded p-2 hover:bg-hover"> 45 | <Icon name="close" size={12} className="text-default" /> 46 | </DialogDismiss> 47 | </div> 48 | 49 | <main className="overflow-y-auto px-4 py-6 text-sans-md text-default"> 50 | {children} 51 | </main> 52 | </Dialog> 53 | </> 54 | ) 55 | } 56 | 57 | export default Modal 58 | -------------------------------------------------------------------------------- /app/components/NewRfdButton.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useDialogStore } from '@ariakit/react' 10 | 11 | import Icon from '~/components/Icon' 12 | import { useRootLoaderData } from '~/root' 13 | 14 | import Modal from './Modal' 15 | 16 | const NewRfdButton = () => { 17 | const dialog = useDialogStore() 18 | const newRfdNumber = useRootLoaderData().newRfdNumber 19 | 20 | return ( 21 | <> 22 | <button 23 | onClick={dialog.toggle} 24 | className="flex h-8 w-8 items-center justify-center rounded border text-tertiary bg-secondary border-secondary elevation-1 hover:bg-tertiary" 25 | > 26 | <Icon name="add-roundel" size={16} /> 27 | </button> 28 | 29 | <Modal dialogStore={dialog} title="Create new RFD"> 30 | <> 31 | <p> 32 | There is a prototype script in the rfd{' '} 33 | <a 34 | href="https://github.com/oxidecomputer/rfd" 35 | className="text-accent-tertiary hover:text-accent-secondary" 36 | > 37 | repository 38 | </a> 39 | ,{' '} 40 | <code className="align-[1px]; ml-[1px] mr-[1px] rounded border px-[4px] py-[1px] text-mono-code bg-raise border-secondary"> 41 | scripts/new.sh 42 | </code> 43 | , that will create a new RFD when used like the code below. 44 | </p> 45 | 46 | <p className="mt-2"> 47 | {newRfdNumber 48 | ? 'The snippet below automatically updates to ensure the new RFD number is correct.' 49 | : 'Replace the number below with the next free number'} 50 | </p> 51 | <pre className="mt-4 overflow-x-auto rounded border px-[1.25rem] py-[1rem] text-mono-code border-secondary 800:px-[1.75rem] 800:py-[1.5rem]"> 52 | <code className="!text-[0.825rem] text-mono-code"> 53 | <span className="mr-2 inline-block select-none text-quaternary">$</span> 54 | scripts/new.sh{' '} 55 | {newRfdNumber ? newRfdNumber.toString().padStart(4, '0') : '0042'} "My title 56 | here" 57 | </code> 58 | </pre> 59 | </> 60 | </Modal> 61 | </> 62 | ) 63 | } 64 | 65 | export default NewRfdButton 66 | -------------------------------------------------------------------------------- /app/components/PublicBanner.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useDialogStore } from '@ariakit/react' 10 | import { Link } from '@remix-run/react' 11 | import { type ReactNode } from 'react' 12 | 13 | import Icon from '~/components/Icon' 14 | 15 | import Modal from './Modal' 16 | 17 | function ExternalLink({ href, children }: { href: string; children: ReactNode }) { 18 | return ( 19 | <a 20 | href={href} 21 | className="text-accent-secondary hover:text-accent" 22 | target="_blank" 23 | rel="noreferrer" 24 | > 25 | {children} 26 | </a> 27 | ) 28 | } 29 | 30 | export function PublicBanner() { 31 | const dialog = useDialogStore() 32 | 33 | return ( 34 | <> 35 | {/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */} 36 | <label className="flex h-10 w-full items-center justify-center text-sans-md text-info-secondary bg-info-secondary print:hidden"> 37 | <Icon name="info" size={16} className="mr-2" /> 38 | Viewing public RFDs. 39 | <button 40 | className="ml-2 flex items-center gap-0.5 text-sans-md hover:text-info" 41 | onClick={() => dialog.toggle()} 42 | > 43 | Learn more <Icon name="next-arrow" size={12} /> 44 | </button> 45 | </label> 46 | 47 | <Modal dialogStore={dialog} title="Oxide Public RFDs"> 48 | <div className="space-y-4"> 49 | <p> 50 | These are the publicly available{' '} 51 | <Link 52 | className="text-accent-secondary hover:text-accent" 53 | to="/rfd/0001" 54 | onClick={() => dialog.setOpen(false)} 55 | > 56 | RFDs 57 | </Link>{' '} 58 | from <ExternalLink href="https://oxide.computer/">Oxide</ExternalLink>. Those 59 | with access should{' '} 60 | <Link className="text-accent-secondary hover:text-accent" to="/login"> 61 | sign in 62 | </Link>{' '} 63 | to view the full directory of RFDs. 64 | </p> 65 | <p> 66 | We use RFDs both to discuss rough ideas and as a permanent repository for more 67 | established ones. You can read more about the{' '} 68 | <ExternalLink href="https://oxide.computer/blog/a-tool-for-discussion"> 69 | tooling around discussions 70 | </ExternalLink> 71 | . 72 | </p> 73 | <p> 74 | If you're interested in the way we work, and would like to see the process from 75 | the inside, check out our{' '} 76 | <ExternalLink href="https://oxide.computer/careers"> 77 | open positions 78 | </ExternalLink> 79 | . 80 | </p> 81 | </div> 82 | </Modal> 83 | </> 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /app/components/StatusBadge.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Badge, type BadgeColor } from '@oxide/design-system' 10 | 11 | const StatusBadge = ({ label }: { label: string }) => { 12 | let color: BadgeColor | undefined 13 | 14 | switch (label) { 15 | case 'prediscussion': 16 | color = 'purple' 17 | break 18 | case 'ideation': 19 | color = 'notice' 20 | break 21 | case 'abandoned': 22 | color = 'neutral' 23 | break 24 | case 'discussion': 25 | color = 'blue' 26 | break 27 | default: 28 | color = 'default' 29 | } 30 | 31 | return <Badge color={color}>{label}</Badge> 32 | } 33 | 34 | export default StatusBadge 35 | -------------------------------------------------------------------------------- /app/components/Suggested.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Link } from '@remix-run/react' 10 | import cn from 'classnames' 11 | import { cloneElement, type ReactElement, type ReactNode } from 'react' 12 | 13 | import Icon from '~/components/Icon' 14 | import type { RfdListItem } from '~/services/rfd.server' 15 | 16 | import type { Author } from './rfd/RfdPreview' 17 | 18 | const Comma = () => <span className="mr-1 inline-block text-notice-tertiary">,</span> 19 | 20 | export const SuggestedAuthors = ({ authors }: { authors: Author[] }) => { 21 | if (authors.length === 0) return null 22 | 23 | return ( 24 | <SuggestedTemplate icon={<Icon name="person" size={16} />} color="purple"> 25 | <div> 26 | <span className="mr-1">Filter RFDs from:</span> 27 | {authors 28 | .filter((a) => a.email) 29 | .map((author, index) => ( 30 | <Link 31 | key={author.name} 32 | to={`/?authorEmail=${author.email}&authorName=${author.name}`} 33 | state={{ shouldClearInput: true }} 34 | className="text-semi-sm underline" 35 | > 36 | {author.name} 37 | {index < authors.length - 1 && <Comma />} 38 | </Link> 39 | ))} 40 | </div> 41 | </SuggestedTemplate> 42 | ) 43 | } 44 | 45 | export const SuggestedLabels = ({ labels }: { labels: string[] }) => { 46 | if (labels.length === 0) return null 47 | 48 | return ( 49 | <SuggestedTemplate icon={<Icon name="tags" size={16} />} color="blue"> 50 | <div> 51 | <span className="mr-1">Filter RFDs labeled:</span> 52 | {labels.map((label, index) => ( 53 | <Link 54 | key={label} 55 | to={`/?label=${label}`} 56 | state={{ shouldClearInput: true }} 57 | className="text-semi-sm underline" 58 | > 59 | {label} 60 | {index < labels.length - 1 && <Comma />} 61 | </Link> 62 | ))} 63 | </div> 64 | </SuggestedTemplate> 65 | ) 66 | } 67 | 68 | export const ExactMatch = ({ rfd }: { rfd: RfdListItem }) => ( 69 | <SuggestedTemplate icon={<Icon name="document" size={16} />} color="green"> 70 | <div> 71 | RFD {rfd.number}:{' '} 72 | <Link 73 | key={rfd.number} 74 | to={`/rfd/${rfd.formattedNumber}`} 75 | state={{ shouldClearInput: true }} 76 | className="text-semi-sm underline" 77 | > 78 | {rfd.title} 79 | </Link> 80 | </div> 81 | </SuggestedTemplate> 82 | ) 83 | 84 | export const SuggestedTemplate = ({ 85 | children, 86 | icon, 87 | color, 88 | }: { 89 | children: ReactNode 90 | icon: ReactElement 91 | color: string 92 | }) => ( 93 | <div className={cn('w-full', `${color}-theme`)}> 94 | <div className="items-top flex w-full rounded px-3 py-2 pr-6 text-sans-sm text-accent bg-accent-secondary"> 95 | {cloneElement(icon, { 96 | className: `mr-2 flex-shrink-0 text-accent-tertiary`, 97 | })} 98 | {children} 99 | </div> 100 | </div> 101 | ) 102 | -------------------------------------------------------------------------------- /app/components/home/FilterDropdown.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Badge, type BadgeColor } from '@oxide/design-system' 10 | import * as Dropdown from '@radix-ui/react-dropdown-menu' 11 | import { useSearchParams } from '@remix-run/react' 12 | import { type ReactNode } from 'react' 13 | 14 | import { 15 | DropdownItem, 16 | DropdownMenu, 17 | DropdownSubMenu, 18 | DropdownSubTrigger, 19 | } from '~/components/Dropdown' 20 | import Icon from '~/components/Icon' 21 | import { useRootLoaderData } from '~/root' 22 | import { classed } from '~/utils/classed' 23 | 24 | const Outline = classed.div`absolute left-0 top-0 z-10 h-[calc(100%+1px)] w-full rounded border border-accent pointer-events-none` 25 | 26 | const FilterDropdown = () => { 27 | const [searchParams, setSearchParams] = useSearchParams() 28 | 29 | const authors = useRootLoaderData().authors 30 | const labels = useRootLoaderData().labels 31 | 32 | const authorNameParam = searchParams.get('authorName') 33 | const authorEmailParam = searchParams.get('authorEmail') 34 | const labelParam = searchParams.get('label') 35 | 36 | const handleFilterAuthor = (email: string, name: string) => { 37 | const sEmail = searchParams.get('authorEmail') 38 | const sName = searchParams.get('authorName') 39 | 40 | if (sEmail === email || sName === name) { 41 | searchParams.delete('authorEmail') 42 | searchParams.delete('authorName') 43 | } else { 44 | searchParams.set('authorEmail', email) 45 | searchParams.set('authorName', name) 46 | } 47 | setSearchParams(searchParams, { replace: true }) 48 | } 49 | 50 | const clearAuthor = () => { 51 | searchParams.delete('authorEmail') 52 | searchParams.delete('authorName') 53 | setSearchParams(searchParams, { replace: true }) 54 | } 55 | 56 | const handleFilterLabel = (label: string) => { 57 | if (labelParam === label) { 58 | searchParams.delete('label') 59 | } else { 60 | searchParams.set('label', label) 61 | } 62 | setSearchParams(searchParams, { replace: true }) 63 | } 64 | 65 | const clearLabel = () => { 66 | searchParams.delete('label') 67 | setSearchParams(searchParams, { replace: true }) 68 | } 69 | 70 | return ( 71 | <div className="flex h-4 items-center text-mono-sm text-default"> 72 | <Dropdown.Root modal={false}> 73 | <Dropdown.Trigger className="-m-2 ml-0 p-2"> 74 | <Icon name="filter" size={12} className="flex-shrink-0" /> 75 | </Dropdown.Trigger> 76 | 77 | <DropdownMenu align="start"> 78 | <Dropdown.Sub> 79 | <DropdownSubTrigger>Authors</DropdownSubTrigger> 80 | <DropdownSubMenu> 81 | {authors 82 | .filter((a) => a.email) 83 | .map((author) => { 84 | const selected = 85 | authorNameParam === author.name || authorEmailParam === author.email 86 | 87 | return ( 88 | <DropdownFilterItem 89 | key={author.name} 90 | selected={selected} 91 | onSelect={() => handleFilterAuthor(author.email, author.name)} 92 | > 93 | {author.name} 94 | </DropdownFilterItem> 95 | ) 96 | })} 97 | </DropdownSubMenu> 98 | </Dropdown.Sub> 99 | <Dropdown.Sub> 100 | <DropdownSubTrigger>Labels</DropdownSubTrigger> 101 | <DropdownSubMenu> 102 | {labels.map((label) => { 103 | const selected = labelParam === label 104 | 105 | return ( 106 | <DropdownFilterItem 107 | key={label} 108 | selected={selected} 109 | onSelect={() => handleFilterLabel(label)} 110 | > 111 | {label} 112 | </DropdownFilterItem> 113 | ) 114 | })} 115 | </DropdownSubMenu> 116 | </Dropdown.Sub> 117 | </DropdownMenu> 118 | </Dropdown.Root> 119 | 120 | {(authorNameParam || authorEmailParam) && ( 121 | <> 122 | <div className="ml-3 mr-1 block text-tertiary">Author:</div> 123 | <FilterBadge onClick={clearAuthor} color="purple"> 124 | {authorNameParam || authorEmailParam} 125 | </FilterBadge> 126 | </> 127 | )} 128 | 129 | {labelParam && ( 130 | <> 131 | <div className="ml-3 mr-1 block text-tertiary">Label:</div> 132 | <FilterBadge onClick={clearLabel} color="blue"> 133 | {labelParam} 134 | </FilterBadge> 135 | </> 136 | )} 137 | </div> 138 | ) 139 | } 140 | 141 | const DropdownFilterItem = ({ 142 | onSelect, 143 | selected, 144 | children, 145 | }: { 146 | onSelect: () => void 147 | selected: boolean 148 | children: ReactNode 149 | }) => ( 150 | <DropdownItem 151 | onSelect={onSelect} 152 | classNames={selected ? 'bg-accent-secondary text-accent' : ''} 153 | > 154 | {selected && <Outline />} 155 | <div className="flex items-center justify-between"> 156 | <div className="max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap"> 157 | {children} 158 | </div> 159 | </div> 160 | </DropdownItem> 161 | ) 162 | 163 | const FilterBadge = ({ 164 | children, 165 | onClick, 166 | color = 'default', 167 | }: { 168 | children: ReactNode 169 | onClick: () => void 170 | color?: BadgeColor 171 | }) => ( 172 | <Badge className="[&>span]:flex [&>span]:items-center" color={color}> 173 | <div className="mr-1">{children}</div> 174 | <button className="-m-4 p-4" onClick={onClick}> 175 | <Icon name="close" size={8} className={`text-${color}`} /> 176 | </button> 177 | </Badge> 178 | ) 179 | 180 | export default FilterDropdown 181 | -------------------------------------------------------------------------------- /app/components/rfd/AccessWarning.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Fragment } from 'react' 10 | 11 | import Icon from '~/components/Icon' 12 | 13 | const AccessWarning = ({ groups }: { groups: string[] }) => { 14 | if (groups.length === 0) return null 15 | 16 | const formatAllowList = (message: string, index: number) => { 17 | if (index < groups.length - 1) { 18 | return ( 19 | <> 20 | {message} 21 | <span className="mr-1 inline-block text-notice-tertiary">,</span> 22 | </> 23 | ) 24 | } else { 25 | return message 26 | } 27 | } 28 | 29 | return ( 30 | <div className="col-span-12 mt-4 flex 800:col-span-10 800:col-start-2 800:pr-10 1000:col-span-10 1000:col-start-2 1200:col-start-3 1200:pr-16"> 31 | <div className="items-top flex w-full rounded px-3 py-2 pr-6 text-sans-md text-notice bg-notice-secondary 1200:w-[calc(100%-var(--toc-width))] print:hidden"> 32 | <Icon name="access" size={16} className="mr-2 flex-shrink-0 text-notice-tertiary" /> 33 | <div> 34 | This RFD can be accessed by the following groups: 35 | <span className="ml-1 inline-block text-notice-tertiary">[</span> 36 | {groups.map((message, index) => ( 37 | <Fragment key={message}>{formatAllowList(message, index)}</Fragment> 38 | ))} 39 | <span className="text-notice-tertiary">]</span> 40 | </div> 41 | </div> 42 | </div> 43 | ) 44 | } 45 | 46 | export default AccessWarning 47 | -------------------------------------------------------------------------------- /app/components/rfd/MoreDropdown.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { useDialogStore } from '@ariakit/react' 9 | import * as Dropdown from '@radix-ui/react-dropdown-menu' 10 | import { useLoaderData } from '@remix-run/react' 11 | import { useState } from 'react' 12 | 13 | import type { loader } from '~/routes/rfd.$slug' 14 | 15 | import { DropdownItem, DropdownLink, DropdownMenu } from '../Dropdown' 16 | import Icon from '../Icon' 17 | import RfdJobsMonitor from './RfdJobsMonitor' 18 | 19 | const MoreDropdown = () => { 20 | const { rfd } = useLoaderData<typeof loader>() 21 | const [dialogOpen, setDialogOpen] = useState(false) 22 | const jobsDialogStore = useDialogStore({ open: dialogOpen, setOpen: setDialogOpen }) 23 | 24 | return ( 25 | <> 26 | <Dropdown.Root modal={false}> 27 | <Dropdown.Trigger className="rounded border p-2 align-[3px] border-default hover:bg-hover"> 28 | <Icon name="more" size={12} className="text-default" /> 29 | </Dropdown.Trigger> 30 | 31 | <DropdownMenu> 32 | <DropdownItem onSelect={jobsDialogStore.toggle}>Processing jobs</DropdownItem> 33 | 34 | <DropdownLink to={rfd.discussion || ''} disabled={!rfd.discussion}> 35 | GitHub discussion 36 | </DropdownLink> 37 | 38 | <DropdownLink to={rfd.link || ''} disabled={!rfd.link}> 39 | GitHub source 40 | </DropdownLink> 41 | 42 | {rfd.link && ( 43 | <DropdownLink 44 | to={`${rfd.link.replace('/tree/', '/blob/')}/README.adoc?plain=1`} 45 | > 46 | Raw AsciiDoc 47 | </DropdownLink> 48 | )} 49 | 50 | <DropdownLink to={`/rfd/${rfd.number}/pdf`}>View PDF</DropdownLink> 51 | </DropdownMenu> 52 | </Dropdown.Root> 53 | 54 | {dialogOpen && ( 55 | <RfdJobsMonitor rfdNumber={rfd.number} dialogStore={jobsDialogStore} /> 56 | )} 57 | </> 58 | ) 59 | } 60 | export default MoreDropdown 61 | -------------------------------------------------------------------------------- /app/components/rfd/RfdJobsMonitor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type useDialogStore } from '@ariakit/react' 9 | import { Badge, Spinner, type BadgeColor } from '@oxide/design-system/components/dist' 10 | import { type Job } from '@oxide/rfd.ts/client' 11 | import { useQuery } from '@tanstack/react-query' 12 | import cn from 'classnames' 13 | import dayjs from 'dayjs' 14 | import relativeTime from 'dayjs/plugin/relativeTime' 15 | import { useState, type ReactNode } from 'react' 16 | 17 | import Icon from '~/components/Icon' 18 | import Modal from '~/components/Modal' 19 | 20 | dayjs.extend(relativeTime) 21 | 22 | type JobStatus = { 23 | label: string 24 | color: BadgeColor 25 | } 26 | 27 | const InfoField = ({ label, children }: { label: string; children: ReactNode }) => ( 28 | <div className="border-r border-default last-of-type:border-0"> 29 | <div className="mb-1 text-mono-sm text-tertiary">{label}</div> 30 | <div className="flex items-center gap-1.5">{children}</div> 31 | </div> 32 | ) 33 | 34 | const KeyValueRow = ({ label, value }: { label: string; value: ReactNode }) => ( 35 | <div className="flex flex-col justify-between border-b px-3 py-2 border-default last-of-type:border-0 600:flex-row 600:items-center"> 36 | <div className="text-mono-sm text-secondary">{label}</div> 37 | <div className="max-w-[200px] truncate 600:max-w-[initial]">{value}</div> 38 | </div> 39 | ) 40 | 41 | const JobDetailsRow = ({ job }: { job: Job }) => { 42 | return ( 43 | <div className="space-y-4 p-4 bg-tertiary 600:p-6"> 44 | <div className="grid grid-cols-1 gap-4 text-sans-md 600:grid-cols-3"> 45 | <InfoField label="Committed"> 46 | {dayjs(job.committedAt).format('MMM D, h:mma')} 47 | </InfoField> 48 | <InfoField label="Created">{dayjs(job.createdAt).format('MMM D, h:mma')}</InfoField> 49 | <InfoField label="Processed"> 50 | {job.processed ? ( 51 | <> 52 | <Icon name="success" size={12} className="text-accent" /> True 53 | </> 54 | ) : ( 55 | <> 56 | <Icon name="unauthorized" size={12} className="text-tertiary" /> False 57 | </> 58 | )} 59 | </InfoField> 60 | </div> 61 | 62 | <div className="w-full rounded-lg border bg-raise border-default"> 63 | <KeyValueRow label="Branch" value={job.branch} /> 64 | <KeyValueRow label="Commit SHA" value={job.sha} /> 65 | <KeyValueRow 66 | label="Webhook ID" 67 | value={ 68 | job.webhookDeliveryId ? ( 69 | job.webhookDeliveryId 70 | ) : ( 71 | <span className="text-tertiary">-</span> 72 | ) 73 | } 74 | /> 75 | </div> 76 | </div> 77 | ) 78 | } 79 | 80 | const JobRow = ({ 81 | job, 82 | isExpanded, 83 | onToggle, 84 | }: { 85 | job: Job 86 | isExpanded: boolean 87 | onToggle: () => void 88 | }) => { 89 | const status = getJobStatus(job) 90 | 91 | return ( 92 | <> 93 | <tr className="cursor-pointer text-sans-md hover:bg-secondary" onClick={onToggle}> 94 | <td className="flex items-center justify-center"> 95 | <Icon 96 | name="next-arrow" 97 | size={12} 98 | className={cn('transition-transform text-tertiary', isExpanded && 'rotate-90')} 99 | /> 100 | </td> 101 | <td>{job.id}</td> 102 | <td className="flex items-center gap-2"> 103 | <Badge color={status.color}>{status.label}</Badge> 104 | {status.label !== 'Completed' && <Spinner />} 105 | </td> 106 | <td className="hidden 600:table-cell"> 107 | <Badge color="neutral" className="!normal-case"> 108 | {job.sha.substring(0, 8)} 109 | </Badge> 110 | </td> 111 | <td>{formatTime(job.startedAt)}</td> 112 | </tr> 113 | {isExpanded && ( 114 | <tr key={`details-${job.id}`}> 115 | <td colSpan={5}> 116 | <JobDetailsRow job={job} /> 117 | </td> 118 | </tr> 119 | )} 120 | </> 121 | ) 122 | } 123 | 124 | const getJobStatus = (job: Job): JobStatus => { 125 | if (job.processed) { 126 | return { 127 | label: 'Completed', 128 | color: 'default', 129 | } 130 | } else if (job.startedAt) { 131 | return { 132 | label: 'In Progress', 133 | color: 'blue', 134 | } 135 | } 136 | 137 | return { 138 | label: 'Queued', 139 | color: 'purple', 140 | } 141 | } 142 | 143 | const formatTime = (dateString?: Date) => { 144 | if (!dateString) return 'N/A' 145 | return dayjs(dateString).fromNow() 146 | } 147 | 148 | async function fetchRfdJobs(rfdNumber: number) { 149 | const response = await fetch(`/rfd/${rfdNumber}/jobs`) 150 | 151 | if (!response.ok) { 152 | throw new Error('Failed to fetch RFD jobs') 153 | } 154 | return response.json() 155 | } 156 | 157 | export default function RfdJobsMonitor({ 158 | rfdNumber, 159 | dialogStore, 160 | }: { 161 | rfdNumber: number 162 | dialogStore: ReturnType<typeof useDialogStore> 163 | }) { 164 | const [expandedJobId, setExpandedJobId] = useState<number | null>(null) 165 | 166 | const { 167 | data: jobs = [], 168 | isLoading, 169 | error, 170 | } = useQuery<Job[]>({ 171 | queryKey: ['rfdJobs', rfdNumber], 172 | queryFn: () => fetchRfdJobs(rfdNumber), 173 | refetchOnWindowFocus: false, 174 | }) 175 | 176 | const toggleExpandJob = (jobId: number) => { 177 | setExpandedJobId(expandedJobId === jobId ? null : jobId) 178 | } 179 | 180 | return ( 181 | <Modal dialogStore={dialogStore} title="RFD Processing Jobs" width="wide"> 182 | {isLoading ? ( 183 | <div className="flex items-center justify-center p-12"> 184 | <Spinner size="lg" /> 185 | </div> 186 | ) : error ? ( 187 | <div className="px-4 py-6 text-center text-error"> 188 | An error occurred while loading jobs. 189 | </div> 190 | ) : ( 191 | <table className="inline-table w-full"> 192 | <thead> 193 | <tr className="text-left"> 194 | <th className="w-8"></th> 195 | <th>Job ID</th> 196 | <th>Status</th> 197 | <th className="hidden 600:table-cell">Commit</th> 198 | <th>Started</th> 199 | </tr> 200 | </thead> 201 | <tbody> 202 | {jobs.length === 0 ? ( 203 | <tr> 204 | <td colSpan={5} className="px-4 py-6 text-center text-tertiary"> 205 | No jobs found 206 | </td> 207 | </tr> 208 | ) : ( 209 | jobs.map((job) => ( 210 | <JobRow 211 | key={job.id} 212 | job={job} 213 | isExpanded={expandedJobId === job.id} 214 | onToggle={() => toggleExpandJob(job.id)} 215 | /> 216 | )) 217 | )} 218 | </tbody> 219 | </table> 220 | )} 221 | </Modal> 222 | ) 223 | } 224 | -------------------------------------------------------------------------------- /app/components/rfd/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .dialog { 10 | opacity: 0; 11 | transition-property: opacity, transform; 12 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 13 | transition-duration: 100ms; 14 | transform: translate3d(50%, 0px, 0px); 15 | } 16 | 17 | .dialog[data-enter] { 18 | opacity: 1; 19 | transition-duration: 100ms; 20 | transform: translate3d(0%, 0px, 0px); 21 | } 22 | 23 | .dialog[data-leave] { 24 | transition-duration: 50ms; 25 | } 26 | 27 | .spinner { 28 | --radius: 4; 29 | --PI: 3.14159265358979; 30 | --circumference: calc(var(--PI) * var(--radius) * 2px); 31 | animation: rotate 5s linear infinite; 32 | } 33 | 34 | .spinner .path { 35 | stroke-dasharray: var(--circumference); 36 | transform-origin: center; 37 | animation: dash 4s ease-in-out infinite; 38 | stroke: var(--content-accent); 39 | } 40 | 41 | @media (prefers-reduced-motion) { 42 | .spinner { 43 | animation: rotate 6s linear infinite; 44 | } 45 | 46 | .spinner .path { 47 | animation: none; 48 | stroke-dasharray: 20; 49 | stroke-dashoffset: 100; 50 | } 51 | 52 | .spinner-lg .path { 53 | stroke-dasharray: 50; 54 | } 55 | } 56 | 57 | .spinner .bg { 58 | stroke: var(--content-default); 59 | } 60 | 61 | @keyframes rotate { 62 | 100% { 63 | transform: rotate(360deg); 64 | } 65 | } 66 | 67 | @keyframes dash { 68 | from { 69 | stroke-dashoffset: var(--circumference); 70 | } 71 | to { 72 | stroke-dashoffset: calc(var(--circumference) * -1); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/hooks/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useSyncExternalStore } from 'react' 10 | 11 | function subscribe() { 12 | return () => {} 13 | } 14 | 15 | /** 16 | * Return a boolean indicating if the JS has been hydrated already. 17 | * When doing Server-Side Rendering, the result will always be false. 18 | * When doing Client-Side Rendering, the result will always be false on the 19 | * first render and true from then on. Even if a new component renders it will 20 | * always start with true. 21 | * 22 | * Example: Disable a button that needs JS to work. 23 | * ```tsx 24 | * let hydrated = useHydrated(); 25 | * return ( 26 | * <button type="button" disabled={!hydrated} onClick={doSomethingCustom}> 27 | * Click me 28 | * </button> 29 | * ); 30 | * ``` 31 | */ 32 | export function useHydrated() { 33 | return useSyncExternalStore( 34 | subscribe, 35 | () => true, 36 | () => false, 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/hooks/use-is-overflow.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import throttle from 'lodash/throttle' 10 | import { useLayoutEffect, useState, type MutableRefObject } from 'react' 11 | 12 | export const useIsOverflow = ( 13 | ref: MutableRefObject<HTMLDivElement | null>, 14 | callback?: (hasOverflow: boolean) => void, 15 | ) => { 16 | const [isOverflow, setIsOverflow] = useState<boolean | undefined>() 17 | const [scrollStart, setScrollStart] = useState<boolean>(true) 18 | const [scrollEnd, setScrollEnd] = useState<boolean>(false) 19 | 20 | useLayoutEffect(() => { 21 | if (!ref?.current) return 22 | 23 | const trigger = () => { 24 | if (!ref?.current) return 25 | const { current } = ref 26 | 27 | const hasOverflow = current.scrollHeight > current.clientHeight 28 | setIsOverflow(hasOverflow) 29 | 30 | if (callback) callback(hasOverflow) 31 | } 32 | 33 | const handleScroll = throttle( 34 | () => { 35 | if (!ref?.current) return 36 | const { current } = ref 37 | 38 | if (current.scrollTop === 0) { 39 | setScrollStart(true) 40 | } else { 41 | setScrollStart(false) 42 | } 43 | 44 | const offsetBottom = current.scrollHeight - current.clientHeight 45 | if (current.scrollTop >= offsetBottom && scrollEnd === false) { 46 | setScrollEnd(true) 47 | } else { 48 | setScrollEnd(false) 49 | } 50 | }, 51 | 125, 52 | { leading: true, trailing: true }, 53 | ) 54 | 55 | trigger() 56 | 57 | const { current } = ref 58 | current.addEventListener('scroll', handleScroll) 59 | window.addEventListener('resize', handleScroll) 60 | return () => { 61 | current.removeEventListener('scroll', handleScroll) 62 | window.removeEventListener('resize', handleScroll) 63 | } 64 | }, [callback, ref, scrollStart, scrollEnd]) 65 | 66 | return { 67 | isOverflow, 68 | scrollStart, 69 | scrollEnd, 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/hooks/use-key.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import Mousetrap from 'mousetrap' 10 | import { useEffect } from 'react' 11 | 12 | type Key = Parameters<typeof Mousetrap.bind>[0] 13 | type Callback = Parameters<typeof Mousetrap.bind>[1] 14 | 15 | /** 16 | * Bind a keyboard shortcut with [Mousetrap](https://craig.is/killing/mice). 17 | * Callback `fn` should be memoized. `key` does not need to be memoized. 18 | */ 19 | export const useKey = (key: Key, fn: Callback) => { 20 | useEffect(() => { 21 | Mousetrap.bind(key, fn) 22 | return () => { 23 | Mousetrap.unbind(key) 24 | } 25 | // JSON.stringify lets us avoid having to memoize the keys at the call site. 26 | // Doing something similar with the callback makes less sense. 27 | /* eslint-disable-next-line react-hooks/exhaustive-deps */ 28 | }, [JSON.stringify(key), fn]) 29 | } 30 | -------------------------------------------------------------------------------- /app/hooks/use-stepped-scroll.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useEffect, type RefObject } from 'react' 10 | 11 | // appreciate this. I suffered 12 | 13 | /** 14 | * While paging through elements in a list one by one, scroll just far enough to 15 | * the selected item in view. Outer container is the one that scrolls. The inner 16 | * container does not scroll, it moves inside the outer container. 17 | */ 18 | export function useSteppedScroll( 19 | outerContainerRef: RefObject<HTMLElement>, 20 | innerContainerRef: RefObject<HTMLElement>, 21 | selectedIdx: number, 22 | itemSelector = 'li', 23 | ) { 24 | useEffect(() => { 25 | const outer = outerContainerRef.current 26 | const inner = innerContainerRef.current 27 | const outerContainerHeight = outer ? outer.clientHeight : 0 28 | 29 | // rather than put refs on all the li, get item by index using the ul ref 30 | const item = inner?.querySelectorAll(itemSelector)[selectedIdx] 31 | if (outer && inner && item) { 32 | // absolute top and bottom of scroll container in viewport. annoyingly, 33 | // the div's bounding client rect bottom is the real bottom, including the 34 | // the part that's scrolled out of view. what we want is the bottom of the 35 | // part that's in view 36 | const outerTop = outer.getBoundingClientRect().top 37 | const outerBottom = outerTop + outerContainerHeight 38 | 39 | // absolute top and bottom of list and item in viewport. outer stays where 40 | // it is, inner moves within it 41 | const { top: itemTop, bottom: itemBottom } = item.getBoundingClientRect() 42 | const { top: innerTop } = inner.getBoundingClientRect() 43 | 44 | // when we decide whether the item we're scrolling to is in view already 45 | // or not, we need to compare absolute positions, i.e., is this item 46 | // inside the visible rectangle or not 47 | const shouldScrollUp = itemTop < outerTop 48 | const shouldScrollDown = itemBottom > outerBottom 49 | 50 | // this probably the most counterintuitive part. now we're trying to tell 51 | // the scrolling container how far to scroll, so we need the position of 52 | // the item relative to the top of the full list (innerTop), not relative 53 | // to the absolute y-position of the top of the visible scrollPort 54 | // (outerTop) 55 | const itemTopScrollTo = itemTop - innerTop - 1 56 | const itemBottomScrollTo = itemBottom - innerTop 57 | 58 | if (shouldScrollUp) { 59 | // when scrolling up, scroll to the top of the item you're scrolling to. 60 | // -1 is for top outline 61 | // -32 compensates for the height of the position: sticky <h3> 62 | outer.scrollTo({ top: itemTopScrollTo - 1 - 24 }) 63 | } else if (shouldScrollDown) { 64 | // when scrolling down, we want to scroll just far enough so the bottom 65 | // edge of the selected item is in view. Because scrollTo is about the 66 | // *top* edge of the scrolling container, that means we scroll to 67 | // LIST_HEIGHT *above* the bottom edge of the item. +2 is for top *and* 68 | // bottom outline 69 | outer.scrollTo({ top: itemBottomScrollTo - outerContainerHeight + 2 }) 70 | } 71 | } 72 | // don't depend on the refs because they get nuked on every render 73 | // eslint-disable-next-line react-hooks/exhaustive-deps 74 | }, [selectedIdx, itemSelector]) 75 | } 76 | -------------------------------------------------------------------------------- /app/hooks/use-window-size.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { useEffect, useState } from 'react' 10 | 11 | function useWindowSize() { 12 | const [size, setSize] = useState<{ 13 | width: number 14 | height: number 15 | }>({ 16 | width: 0, 17 | height: 0, 18 | }) 19 | 20 | const [hasLargeScreen, setHasLargeScreen] = useState<boolean>(false) 21 | 22 | useEffect(() => { 23 | // Only execute all the code below in client side 24 | if (typeof window !== 'undefined') { 25 | // Handler to call on window resize 26 | function handleResize() { 27 | // Set window width/height to state 28 | setSize({ 29 | width: window.innerWidth, 30 | height: window.innerHeight, 31 | }) 32 | 33 | setHasLargeScreen(window.innerWidth >= 800) 34 | } 35 | 36 | window.addEventListener('resize', handleResize) 37 | 38 | handleResize() 39 | 40 | return () => window.removeEventListener('resize', handleResize) 41 | } 42 | }, []) 43 | 44 | return { 45 | size, 46 | hasLargeScreen, 47 | } 48 | } 49 | 50 | export default useWindowSize 51 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { 10 | type LinksFunction, 11 | type LoaderFunctionArgs, 12 | type MetaFunction, 13 | type SerializeFrom, 14 | } from '@remix-run/node' 15 | import { 16 | isRouteErrorResponse, 17 | Links, 18 | Meta, 19 | Outlet, 20 | Scripts, 21 | ScrollRestoration, 22 | useLoaderData, 23 | useRouteError, 24 | useRouteLoaderData, 25 | } from '@remix-run/react' 26 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 27 | 28 | import { auth, isAuthenticated } from '~/services/authn.server' 29 | import styles from '~/styles/index.css?url' 30 | 31 | import LoadingBar from './components/LoadingBar' 32 | import { inlineCommentsCookie, themeCookie } from './services/cookies.server' 33 | import { isLocalMode } from './services/rfd.local.server' 34 | import { 35 | fetchRfds, 36 | getAuthors, 37 | getLabels, 38 | provideNewRfdNumber, 39 | } from './services/rfd.server' 40 | 41 | export const meta: MetaFunction = () => { 42 | return [{ title: 'RFD / Oxide' }] 43 | } 44 | 45 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] 46 | 47 | export const loader = async ({ request }: LoaderFunctionArgs) => { 48 | let theme = (await themeCookie.parse(request.headers.get('Cookie'))) ?? 'dark-mode' 49 | let inlineComments = 50 | (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true 51 | 52 | const user = await isAuthenticated(request) 53 | try { 54 | const rfds = (await fetchRfds(user)) || [] 55 | 56 | const authors = rfds ? getAuthors(rfds) : [] 57 | const labels = rfds ? getLabels(rfds) : [] 58 | 59 | return { 60 | // Any data added to the ENV key of this loader will be injected into the 61 | // global window object (window.ENV) 62 | theme, 63 | inlineComments, 64 | user, 65 | rfds, 66 | authors, 67 | labels, 68 | localMode: isLocalMode(), 69 | newRfdNumber: provideNewRfdNumber([...rfds]), 70 | } 71 | } catch (err) { 72 | // The only error that should be caught here is the unauthenticated error. 73 | // And if that occurs we need to log the user out 74 | await auth.logout(request, { redirectTo: '/' }) 75 | } 76 | 77 | // Convince remix that a return type will always be provided 78 | return { 79 | theme, 80 | inlineComments, 81 | user, 82 | rfds: [], 83 | authors: [], 84 | labels: [], 85 | localMode: isLocalMode(), 86 | newRfdNumber: undefined, 87 | } 88 | } 89 | 90 | export function useRootLoaderData() { 91 | return useRouteLoaderData('root') as SerializeFrom<typeof loader> 92 | } 93 | 94 | export function ErrorBoundary() { 95 | const error = useRouteError() 96 | 97 | let message = 'Something went wrong' 98 | 99 | if (isRouteErrorResponse(error)) { 100 | if (error.status === 404) { 101 | message = '404 Not Found' 102 | } 103 | } 104 | 105 | return ( 106 | <Layout> 107 | <div className="flex h-full w-full items-center justify-center"> 108 | <h1 className="text-2xl">{message}</h1> 109 | </div> 110 | </Layout> 111 | ) 112 | } 113 | const queryClient = new QueryClient() 114 | 115 | const Layout = ({ children, theme }: { children: React.ReactNode; theme?: string }) => ( 116 | <html lang="en" className={theme}> 117 | <head> 118 | <meta charSet="utf-8" /> 119 | <meta name="viewport" content="width=device-width,initial-scale=1" /> 120 | <Meta /> 121 | <Links /> 122 | <link rel="icon" href="/favicon.svg" /> 123 | <link rel="icon" type="image/png" href="/favicon.png" /> 124 | <meta name="viewport" content="width=device-width, initial-scale=1" /> 125 | {/* Use plausible analytics only on Vercel */} 126 | {process.env.NODE_ENV === 'production' && ( 127 | <script defer data-domain="rfd.shared.oxide.computer" src="/js/viewscript.js" /> 128 | )} 129 | </head> 130 | <body className="mb-32"> 131 | {children} 132 | <ScrollRestoration /> 133 | <Scripts /> 134 | </body> 135 | </html> 136 | ) 137 | 138 | export default function App() { 139 | const { theme, localMode } = useLoaderData<typeof loader>() 140 | 141 | return ( 142 | <Layout theme={theme}> 143 | <LoadingBar /> 144 | <QueryClientProvider client={queryClient}> 145 | <Outlet /> 146 | {localMode && ( 147 | <div className="overlay-shadow fixed bottom-6 left-6 z-10 rounded p-2 text-sans-sm text-notice bg-notice-secondary"> 148 | Local authoring mode 149 | </div> 150 | )} 151 | </QueryClientProvider> 152 | </Layout> 153 | ) 154 | } 155 | -------------------------------------------------------------------------------- /app/routes/$slug.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type LoaderFunctionArgs } from '@remix-run/node' 10 | 11 | import { parseRfdNum } from '~/utils/parseRfdNum' 12 | 13 | export async function loader({ params: { slug } }: LoaderFunctionArgs) { 14 | if (parseRfdNum(slug)) { 15 | return redirect(`/rfd/${slug}`) 16 | } else { 17 | throw new Response('Not Found', { status: 404 }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/routes/auth.github.callback.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { LoaderFunction } from '@remix-run/node' 10 | 11 | import { handleAuthenticationCallback } from '~/services/authn.server' 12 | 13 | export let loader: LoaderFunction = async ({ request }) => { 14 | return handleAuthenticationCallback('rfd-api-github', request) 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/auth.github.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type ActionFunction, type LoaderFunction } from '@remix-run/node' 10 | 11 | import { auth } from '~/services/authn.server' 12 | 13 | export let loader: LoaderFunction = () => redirect('/login') 14 | 15 | export let action: ActionFunction = ({ request }) => { 16 | return auth.authenticate('rfd-api-github', request) 17 | } 18 | -------------------------------------------------------------------------------- /app/routes/auth.google.callback.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { LoaderFunction } from '@remix-run/node' 10 | 11 | import { handleAuthenticationCallback } from '~/services/authn.server' 12 | 13 | export let loader: LoaderFunction = async ({ request }) => { 14 | return handleAuthenticationCallback('rfd-api-google', request) 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/auth.google.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type ActionFunction, type LoaderFunction } from '@remix-run/node' 10 | 11 | import { auth } from '~/services/authn.server' 12 | 13 | export let loader: LoaderFunction = () => redirect('/login') 14 | 15 | export let action: ActionFunction = ({ request }) => { 16 | return auth.authenticate('rfd-api-google', request) 17 | } 18 | -------------------------------------------------------------------------------- /app/routes/local-img.$.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import fs from 'fs/promises' 10 | import type { LoaderFunctionArgs } from '@remix-run/node' 11 | import { lookup } from 'mime-types' 12 | 13 | // serve images (technically any file) straight from local rfd repo when we're 14 | // in local mode 15 | 16 | export async function loader({ params }: LoaderFunctionArgs) { 17 | const filename = params['*'] 18 | 19 | // endpoint will 404 unless we're in local RFD mode 20 | if (!filename || process.env.NODE_ENV !== 'development' || !process.env.LOCAL_RFD_REPO) { 21 | throw new Response('Not Found', { status: 404 }) 22 | } 23 | 24 | try { 25 | const buffer = await fs.readFile(`${process.env.LOCAL_RFD_REPO}/rfd/${filename}`) 26 | 27 | return new Response(buffer, { 28 | headers: { 29 | 'Content-Type': lookup(filename) || 'text/html', 30 | }, 31 | }) 32 | } catch (e) { 33 | throw new Response('Not Found', { status: 404 }) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { Button } from '@oxide/design-system' 10 | import { json, type LoaderFunction } from '@remix-run/node' 11 | import { Form } from '@remix-run/react' 12 | 13 | import { isAuthenticated } from '~/services/authn.server' 14 | import { returnToCookie } from '~/services/cookies.server' 15 | import { getUserRedirect } from '~/services/redirect.server' 16 | 17 | export let loader: LoaderFunction = async ({ request }) => { 18 | const url = new URL(request.url) 19 | const returnTo = url.searchParams.get('returnTo') 20 | 21 | // if we're already logged in, go straight to returnTo 22 | await isAuthenticated(request, { 23 | successRedirect: getUserRedirect(returnTo), 24 | }) 25 | 26 | const headers = new Headers() 27 | headers.append('Cache-Control', 'no-cache') 28 | 29 | if (returnTo) { 30 | headers.append('Set-Cookie', await returnToCookie.serialize(returnTo)) 31 | } 32 | 33 | return json(null, { headers }) 34 | } 35 | 36 | export default function Login() { 37 | return ( 38 | <> 39 | <div 40 | className="fixed h-screen w-screen opacity-80" 41 | style={{ 42 | background: 43 | 'radial-gradient(200% 100% at 50% 100%, #161B1D 0%, var(--surface-default) 100%)', 44 | }} 45 | > 46 | <div className="flex h-[var(--header-height)] w-full items-center justify-between border-b px-3 border-b-secondary"> 47 | <div className="space-y-1"> 48 | <div className="h-3 w-16 rounded bg-secondary" /> 49 | <div className="h-3 w-24 rounded bg-secondary" /> 50 | </div> 51 | 52 | <div className="h-6 w-24 rounded bg-secondary" /> 53 | </div> 54 | 55 | <div className="mt-20 w-full border-b pb-16 border-b-secondary"> 56 | <div className="mx-auto w-2/3 max-w-[1080px]"> 57 | <div className="h-10 w-full rounded bg-secondary" /> 58 | <div className="mt-4 h-10 w-2/3 rounded bg-secondary" /> 59 | </div> 60 | </div> 61 | 62 | <div className="absolute bottom-0 h-[var(--header-height)] w-full border-t border-t-secondary"></div> 63 | </div> 64 | <div className="overlay-shadow fixed left-1/2 top-1/2 w-[calc(100%-2.5rem)] -translate-x-1/2 -translate-y-1/2 space-y-4 rounded-lg border p-8 text-center bg-raise border-secondary 600:w-[24rem]"> 65 | <h1 className="mb-8 text-sans-2xl text-accent">Sign in</h1> 66 | <Form action="/auth/google" method="post"> 67 | <Button className="w-full" type="submit"> 68 | Continue with Google 69 | </Button> 70 | </Form> 71 | <Form action="/auth/github" method="post"> 72 | <Button className="w-full" variant="secondary" type="submit"> 73 | Continue with GitHub 74 | </Button> 75 | </Form> 76 | </div> 77 | </> 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { ActionFunction } from '@remix-run/node' 10 | 11 | import { auth } from '~/services/authn.server' 12 | 13 | export const action: ActionFunction = async ({ request }) => { 14 | await auth.logout(request, { redirectTo: '/' }) 15 | } 16 | -------------------------------------------------------------------------------- /app/routes/rfd.$slug.discussion.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type LoaderFunctionArgs } from '@remix-run/node' 10 | 11 | import { isAuthenticated } from '~/services/authn.server' 12 | import { fetchRfd } from '~/services/rfd.server' 13 | import { parseRfdNum } from '~/utils/parseRfdNum' 14 | 15 | import { resp404 } from './rfd.$slug' 16 | 17 | export async function loader({ request, params: { slug } }: LoaderFunctionArgs) { 18 | const num = parseRfdNum(slug) 19 | if (!num) throw resp404() 20 | 21 | const user = await isAuthenticated(request) 22 | const rfd = await fetchRfd(num, user) 23 | 24 | // !rfd covers both non-existent and private RFDs for the logged-out user. In 25 | // both cases, once they log in, if they have permission to read it, they'll 26 | // get the redirect, otherwise they will get 404. 27 | if (!rfd && !user) throw redirect(`/login?returnTo=/rfd/${num}/discussion`) 28 | 29 | // If you don't see an RFD but you are logged in, you can't tell whether you 30 | // don't have access or it doesn't exist. That's fine. 31 | if (!rfd || !rfd.discussion) throw resp404() 32 | 33 | return redirect(rfd.discussion) 34 | } 35 | -------------------------------------------------------------------------------- /app/routes/rfd.$slug.fetch.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { LoaderFunction } from '@remix-run/node' 10 | 11 | import { isAuthenticated } from '~/services/authn.server' 12 | import { fetchRfd } from '~/services/rfd.server' 13 | import { parseRfdNum } from '~/utils/parseRfdNum' 14 | 15 | export let loader: LoaderFunction = async ({ request, params: { slug } }) => { 16 | const num = parseRfdNum(slug) 17 | if (!num) throw new Response('Not Found', { status: 404 }) 18 | 19 | const user = await isAuthenticated(request) 20 | const rfd = await fetchRfd(num, user) 21 | 22 | if (!rfd) throw new Response('Not Found', { status: 404 }) 23 | 24 | return rfd 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/rfd.$slug.jobs.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { json, type LoaderFunctionArgs } from '@remix-run/node' 9 | 10 | import { isAuthenticated } from '~/services/authn.server' 11 | import { fetchRfdJobs } from '~/services/rfd.server' 12 | 13 | export async function loader({ request, params }: LoaderFunctionArgs) { 14 | const rfdNumber = parseInt(params.slug || '') 15 | 16 | if (isNaN(rfdNumber)) { 17 | return json({ error: 'Invalid RFD number' }, { status: 400 }) 18 | } 19 | 20 | const user = await isAuthenticated(request) 21 | const jobs = await fetchRfdJobs(rfdNumber, user) 22 | 23 | return json(jobs) 24 | } 25 | -------------------------------------------------------------------------------- /app/routes/rfd.$slug.pdf.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type LoaderFunction } from '@remix-run/node' 10 | 11 | import { isAuthenticated } from '~/services/authn.server' 12 | import { fetchRfdPdf } from '~/services/rfd.server' 13 | import { parseRfdNum } from '~/utils/parseRfdNum' 14 | 15 | export let loader: LoaderFunction = async ({ request, params: { slug } }) => { 16 | const num = parseRfdNum(slug) 17 | if (!num) throw new Response('Not Found', { status: 404 }) 18 | 19 | const user = await isAuthenticated(request) 20 | const rfd = await fetchRfdPdf(num, user) 21 | 22 | if (!rfd || rfd.content.length === 0) throw new Response('Not Found', { status: 404 }) 23 | 24 | throw redirect(rfd.content[0].link) 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/rfd.image.$rfd.$.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { redirect, type LoaderFunctionArgs } from '@remix-run/node' 10 | 11 | import { isAuthenticated } from '~/services/authn.server' 12 | import { fetchLocalImage, isLocalMode } from '~/services/rfd.local.server' 13 | import { fetchRfd } from '~/services/rfd.server' 14 | import { getExpiringUrl } from '~/services/storage.server' 15 | 16 | export async function loader({ request, params }: LoaderFunctionArgs) { 17 | const rfd = params['rfd'] 18 | const filename = params['*'] 19 | 20 | if (!rfd || !filename) throw new Response('Not Found', { status: 404 }) 21 | 22 | const rfdNumber = parseInt(rfd, 10) 23 | 24 | if (isLocalMode()) { 25 | const localImage = fetchLocalImage(rfdNumber, decodeURI(filename)) 26 | if (!localImage) { 27 | throw new Response('Unable to retrieve image', { status: 500 }) 28 | } 29 | 30 | const fileExt = filename.split('.').pop() 31 | 32 | let type = fileExt 33 | if (fileExt === 'svg') { 34 | type = 'svg+xml' 35 | } 36 | 37 | return new Response(localImage, { 38 | headers: { 39 | 'Content-Type': `image/${type}`, 40 | }, 41 | }) 42 | } else { 43 | const user = await isAuthenticated(request) 44 | const remoteRfd = await fetchRfd(rfdNumber, user) 45 | 46 | // If the user can read the RFD than they can access the images in the RFD 47 | if (remoteRfd) { 48 | const path = `rfd/${rfd}/latest/${filename}` 49 | 50 | // Default expiration of one day. The intention here is to provide a window of time that allows 51 | // for daily usage of the site, but does not allow for indefinite access 52 | const defaultExpiration = 24 * 60 * 60 53 | 54 | return redirect(getExpiringUrl(path, defaultExpiration)) 55 | } else { 56 | throw new Response('Forbidden', { 57 | status: 403, 58 | }) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/search.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { SearchResults } from '@oxide/rfd.ts/client' 10 | import { type LoaderFunctionArgs } from '@remix-run/node' 11 | 12 | import { auth } from '~/services/authn.server' 13 | import { searchRfds } from '~/services/rfd.remote.server' 14 | 15 | export async function loader({ request }: LoaderFunctionArgs) { 16 | const user = await auth.isAuthenticated(request) 17 | const url = new URL(request.url) 18 | 19 | const results = await searchRfds(user, url.searchParams.entries()) 20 | return adaptResults(results) 21 | } 22 | 23 | function adaptResults(results: SearchResults) { 24 | const hits = results.hits.map((hit) => { 25 | const highlightResult: any = { 26 | content: { 27 | value: hit.formatted?.content, 28 | }, 29 | objectID: { 30 | value: hit.formatted?.objectId, 31 | }, 32 | rfd_number: { 33 | value: `${hit.formatted?.rfdNumber}`, 34 | }, 35 | anchor: { 36 | value: hit.formatted?.anchor, 37 | }, 38 | url: { 39 | value: hit.formatted?.url || '', 40 | }, 41 | } 42 | 43 | if (hit.formatted?.hierarchy) { 44 | for (const i in hit.formatted?.hierarchy) { 45 | if (hit.formatted?.hierarchy[i]) { 46 | highlightResult[`hierarchy_lvl${i}`] = { 47 | value: hit.formatted?.hierarchy[i], 48 | } 49 | } 50 | } 51 | } 52 | 53 | if (hit.formatted?.hierarchyRadio) { 54 | for (const i in hit.formatted?.hierarchyRadio) { 55 | if (hit.formatted?.hierarchyRadio[i]) { 56 | highlightResult[`hierarchy_radio_lvl${i}`] = { 57 | value: hit.formatted?.hierarchyRadio[i], 58 | } 59 | break 60 | } 61 | } 62 | } 63 | 64 | const adaptedHit: any = { 65 | content: hit.content, 66 | objectID: hit.objectId, 67 | rfd_number: hit.rfdNumber, 68 | anchor: hit.anchor, 69 | url: hit.url || '', 70 | _highlightResult: highlightResult, 71 | _snippetResult: highlightResult, 72 | } 73 | 74 | for (const i in hit.hierarchy) { 75 | if (hit.hierarchy[i]) { 76 | adaptedHit[`hierarchy_lvl${i}`] = hit.hierarchy[i] 77 | } 78 | } 79 | 80 | for (const i in hit.hierarchyRadio) { 81 | if (hit.hierarchyRadio[i]) { 82 | adaptedHit[`hierarchy_radio_lvl${i}`] = hit.hierarchyRadio[i] 83 | break 84 | } 85 | } 86 | 87 | return adaptedHit 88 | }) 89 | 90 | return { 91 | results: [ 92 | { 93 | index: 'rfd', 94 | hitsPerPage: results.limit, 95 | page: 0, 96 | facets: {}, 97 | nbPages: 1, 98 | nbHits: results.limit, 99 | processingTimeMS: 0, 100 | query: results.query, 101 | hits, 102 | }, 103 | ], 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/routes/sitemap[.]xml.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import * as R from 'remeda' 10 | 11 | import { fetchRfds } from '~/services/rfd.server' 12 | 13 | function url(path: string, lastmod?: Date) { 14 | const lines = [' <url>'] 15 | lines.push(` <loc>https://rfd.shared.oxide.computer${path}</loc>`) 16 | if (lastmod) lines.push(` <lastmod>${lastmod.toISOString().slice(0, 10)}</lastmod>`) 17 | lines.push(` </url>`) 18 | return lines.join('\n') 19 | } 20 | 21 | export async function loader() { 22 | // null user means we only get public RFDs 23 | const rfds = (await fetchRfds(null)) || [] 24 | 25 | const rfdUrls = R.pipe( 26 | rfds, 27 | R.sortBy((rfd) => rfd.formattedNumber), 28 | R.map((rfd) => url(`/rfd/${rfd.formattedNumber}`, rfd.committedAt)), 29 | R.join('\n'), 30 | ) 31 | 32 | const content = ` 33 | <?xml version="1.0" encoding="UTF-8"?> 34 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 35 | ${url('/')} 36 | ${rfdUrls} 37 | </urlset>`.trim() 38 | 39 | return new Response(content, { 40 | headers: { 'Content-Type': 'application/xml' }, 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/user.toggle-inline-comments.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { json, type ActionFunction } from '@remix-run/node' 10 | 11 | import { inlineCommentsCookie } from '~/services/cookies.server' 12 | 13 | export const action: ActionFunction = async ({ request }) => { 14 | let showInlineComments = 15 | (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true 16 | 17 | let headers = new Headers({ 'Cache-Control': 'no-cache' }) 18 | const newVal = await inlineCommentsCookie.serialize(showInlineComments === false) 19 | headers.append('Set-Cookie', newVal) 20 | 21 | return json(null, { headers }) 22 | } 23 | -------------------------------------------------------------------------------- /app/routes/user.toggle-theme.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { json, type ActionFunction } from '@remix-run/node' 10 | 11 | import { themeCookie } from '~/services/cookies.server' 12 | 13 | export const action: ActionFunction = async ({ request }) => { 14 | let currentTheme = (await themeCookie.parse(request.headers.get('Cookie'))) ?? 'dark-mode' 15 | 16 | let headers = new Headers() 17 | headers.append('Cache-Control', 'no-cache') 18 | headers.append( 19 | 'Set-Cookie', 20 | await themeCookie.serialize(currentTheme === 'light-mode' ? 'dark-mode' : 'light-mode'), 21 | ) 22 | 23 | return json(null, { headers }) 24 | } 25 | -------------------------------------------------------------------------------- /app/services/authn.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { RfdPermission } from '@oxide/rfd.ts/client' 10 | import { Authenticator } from 'remix-auth' 11 | 12 | import { sessionStorage } from '~/services/session.server' 13 | import { isTruthy } from '~/utils/isTruthy' 14 | import type { RfdScope } from '~/utils/rfdApi' 15 | import { 16 | RfdApiStrategy, 17 | type RfdApiAccessToken, 18 | type RfdApiProfile, 19 | } from '~/utils/rfdApiStrategy' 20 | 21 | import { returnToCookie } from './cookies.server' 22 | import { getUserRedirect } from './redirect.server' 23 | import { isLocalMode } from './rfd.local.server' 24 | import { client, fetchRemoteGroups, getGroups, getRfdApiUrl } from './rfd.remote.server' 25 | 26 | export type AuthenticationService = 'github' | 'google' | 'local' 27 | 28 | const scope: RfdScope[] = [ 29 | 'group:info:r', 30 | 'rfd:content:r', 31 | 'rfd:discussion:r', 32 | 'search', 33 | 'user:info:r', 34 | ] 35 | 36 | export type User = { 37 | id: string 38 | authenticator: AuthenticationService 39 | email: string | null 40 | displayName: string | null 41 | token: string 42 | permissions: RfdPermission[] 43 | groups: string[] 44 | expiresAt: number 45 | } 46 | 47 | export type Group = { 48 | id: string 49 | name: string 50 | permissions: RfdPermission[] 51 | } 52 | 53 | export async function getUserPermissions(user: User): Promise<RfdPermission[]> { 54 | const groups = (await fetchRemoteGroups(user)).filter((group) => 55 | user.groups.includes(group.name), 56 | ) 57 | const allPermissions = user.permissions.concat( 58 | groups.flatMap((group) => group.permissions), 59 | ) 60 | return allPermissions 61 | } 62 | 63 | async function parseUser( 64 | service: AuthenticationService, 65 | accessToken: string, 66 | profile: RfdApiProfile, 67 | ): Promise<User> { 68 | const parsedToken: RfdApiAccessToken = JSON.parse( 69 | Buffer.from(accessToken.split('.')[1] || '', 'base64').toString('utf8'), 70 | ) 71 | const rfdClient = client(accessToken) 72 | const groups = await getGroups(rfdClient) 73 | 74 | return { 75 | id: profile._raw.info.id, 76 | authenticator: service, 77 | email: profile.emails?.[0].value || '', 78 | displayName: profile.displayName ?? null, 79 | token: accessToken, 80 | permissions: profile._raw.info.permissions, 81 | groups: profile._raw.info.groups 82 | .map((groupId) => { 83 | return groups.find((group) => group.id === groupId)?.name 84 | }) 85 | .filter(isTruthy), 86 | expiresAt: parsedToken.exp, 87 | } 88 | } 89 | 90 | const auth = new Authenticator<User>(sessionStorage) 91 | 92 | auth.use( 93 | new RfdApiStrategy( 94 | { 95 | host: getRfdApiUrl(), 96 | clientID: process.env.RFD_API_CLIENT_ID || '', 97 | clientSecret: process.env.RFD_API_CLIENT_SECRET || '', 98 | callbackURL: process.env.RFD_API_GOOGLE_CALLBACK_URL || '', 99 | remoteProvider: 'google', 100 | scope, 101 | }, 102 | async ({ accessToken, profile }) => { 103 | return parseUser('google', accessToken, profile) 104 | }, 105 | ), 106 | ) 107 | 108 | auth.use( 109 | new RfdApiStrategy( 110 | { 111 | host: getRfdApiUrl(), 112 | clientID: process.env.RFD_API_CLIENT_ID || '', 113 | clientSecret: process.env.RFD_API_CLIENT_SECRET || '', 114 | callbackURL: process.env.RFD_API_GITHUB_CALLBACK_URL || '', 115 | remoteProvider: 'github', 116 | scope, 117 | }, 118 | async ({ accessToken, profile }) => { 119 | return parseUser('github', accessToken, profile) 120 | }, 121 | ), 122 | ) 123 | 124 | async function isAuthenticated( 125 | request: Request, 126 | options?: { 127 | successRedirect?: never 128 | failureRedirect?: never 129 | }, 130 | ): Promise<User | null> 131 | async function isAuthenticated( 132 | request: Request, 133 | options: { 134 | successRedirect: string 135 | failureRedirect?: never 136 | }, 137 | ): Promise<null> 138 | async function isAuthenticated( 139 | request: Request, 140 | options: { 141 | successRedirect?: never 142 | failureRedirect: string 143 | }, 144 | ): Promise<User> 145 | async function isAuthenticated(request: Request, options: any): Promise<User | null> { 146 | if (isLocalMode()) { 147 | const user: User = { 148 | id: 'none', 149 | authenticator: 'local', 150 | email: 'local@oxide.computer', 151 | displayName: 'local', 152 | token: '', 153 | permissions: [], 154 | groups: [], 155 | expiresAt: 9999999999, 156 | } 157 | return Promise.resolve(user) 158 | } else { 159 | const user = await auth.isAuthenticated(request, options) 160 | return user 161 | } 162 | } 163 | 164 | async function handleAuthenticationCallback(provider: string, request: Request) { 165 | const cookie = request.headers.get('Cookie') 166 | const returnTo: string | null = await returnToCookie.parse(cookie) 167 | 168 | return auth.authenticate(provider, request, { 169 | successRedirect: getUserRedirect(returnTo), 170 | failureRedirect: '/login', 171 | }) 172 | } 173 | 174 | export { auth, isAuthenticated, handleAuthenticationCallback } 175 | -------------------------------------------------------------------------------- /app/services/cookies.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { createCookie } from '@remix-run/node' 10 | 11 | export const returnToCookie = createCookie('_return_to', { 12 | sameSite: 'lax', 13 | path: '/', 14 | httpOnly: true, 15 | secrets: ['s3cr3t'], 16 | maxAge: 60 * 10, // 10 minutes 17 | secure: process.env.NODE_ENV === 'production', 18 | }) 19 | 20 | export const themeCookie = createCookie('_theme', { 21 | sameSite: 'lax', 22 | path: '/', 23 | httpOnly: true, 24 | secrets: ['s3cr3t'], 25 | secure: process.env.NODE_ENV === 'production', 26 | maxAge: 60 * 60 * 24 * 365, // Keep cookie for a year 27 | }) 28 | 29 | export const rfdSortCookie = createCookie('rfdSort', { 30 | sameSite: 'lax', 31 | path: '/', 32 | httpOnly: true, 33 | maxAge: 60 * 60 * 24 * 365, // Keep cookie for a year 34 | }) 35 | 36 | export const inlineCommentsCookie = createCookie('_inline_comments', { 37 | sameSite: 'lax', 38 | path: '/', 39 | httpOnly: true, 40 | secrets: ['s3cr3t'], 41 | secure: process.env.NODE_ENV === 'production', 42 | maxAge: 60 * 60 * 24 * 365, // Keep cookie for a year 43 | }) 44 | -------------------------------------------------------------------------------- /app/services/github-discussion.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { createAppAuth } from '@octokit/auth-app' 10 | import type { GetResponseTypeFromEndpointMethod } from '@octokit/types' 11 | import { Octokit } from 'octokit' 12 | 13 | import { any } from '~/utils/permission' 14 | 15 | import { getUserPermissions, type User } from './authn.server' 16 | 17 | function getOctokitClient() { 18 | if (process.env.GITHUB_API_KEY) { 19 | return new Octokit({ 20 | auth: process.env.GITHUB_API_KEY, 21 | }) 22 | } else if ( 23 | process.env.GITHUB_APP_ID && 24 | process.env.GITHUB_INSTALLATION_ID && 25 | process.env.GITHUB_PRIVATE_KEY 26 | ) { 27 | return new Octokit({ 28 | authStrategy: createAppAuth, 29 | auth: { 30 | appId: process.env.GITHUB_APP_ID, 31 | privateKey: process.env.GITHUB_PRIVATE_KEY, 32 | installationId: process.env.GITHUB_INSTALLATION_ID, 33 | }, 34 | }) 35 | } else { 36 | return null 37 | } 38 | } 39 | 40 | export type ListReviewsResponseType = GetResponseTypeFromEndpointMethod< 41 | Octokit['rest']['pulls']['listReviews'] 42 | > 43 | 44 | export type ListReviewsCommentsResponseType = GetResponseTypeFromEndpointMethod< 45 | Octokit['rest']['pulls']['listReviewComments'] 46 | > 47 | 48 | export type ListIssueCommentsResponseType = GetResponseTypeFromEndpointMethod< 49 | Octokit['rest']['issues']['listComments'] 50 | > 51 | 52 | export type ListReviewsType = ListReviewsResponseType['data'] 53 | 54 | export type ReviewType = ListReviewsType[number] 55 | 56 | export type ListReviewsCommentsType = ListReviewsCommentsResponseType['data'] 57 | 58 | export type ReviewCommentsType = ListReviewsCommentsType[number] 59 | 60 | export type ListIssueCommentsType = ListIssueCommentsResponseType['data'] 61 | 62 | export type IssueCommentType = ListIssueCommentsType[number] 63 | 64 | /** Includes auth check: non-internal users can't see discussion */ 65 | export async function fetchDiscussion( 66 | rfd: number, 67 | discussionLink: string | undefined, 68 | user: User | null, 69 | ): Promise<{ 70 | reviews: ListReviewsType 71 | comments: ListReviewsCommentsType 72 | prComments: ListIssueCommentsType 73 | pullNumber: number 74 | } | null> { 75 | const octokit = getOctokitClient() 76 | 77 | if (!octokit) return null 78 | if (!discussionLink) return null 79 | if (!user) return null 80 | const userPermissions = await getUserPermissions(user) 81 | if (!(await any(userPermissions, [{ GetDiscussion: rfd }, 'GetDiscussionsAll']))) 82 | return null 83 | 84 | const match = discussionLink.match(/\/pull\/(\d+)$/) 85 | if (!match) return null 86 | 87 | const pullNumber = parseInt(match[1], 10) 88 | 89 | const reviews: ListReviewsResponseType = await octokit.rest.pulls.listReviews({ 90 | owner: 'oxidecomputer', 91 | repo: 'rfd', 92 | pull_number: pullNumber, 93 | per_page: 100, 94 | }) 95 | 96 | if (reviews.status !== 200) { 97 | console.error('Error fetching reviews from GitHub') 98 | return null 99 | } 100 | 101 | // Paginate review comments 102 | // This is _sloooowwww_ 103 | // To be resolved by moving to our own mirror / CIO 104 | let comments: ListReviewsCommentsType = [] 105 | await octokit 106 | .paginate(octokit.rest.pulls.listReviewComments, { 107 | owner: 'oxidecomputer', 108 | repo: 'rfd', 109 | pull_number: pullNumber, 110 | per_page: 100, 111 | }) 112 | .then((data) => { 113 | comments = data 114 | }) 115 | .catch(() => { 116 | console.error('Error fetching comments from GitHub') 117 | return null 118 | }) 119 | 120 | let prComments = await octokit.paginate(octokit.rest.issues.listComments, { 121 | owner: 'oxidecomputer', 122 | repo: 'rfd', 123 | issue_number: pullNumber, 124 | per_page: 100, 125 | }) 126 | 127 | return { reviews: reviews.data, comments: comments, prComments, pullNumber } 128 | } 129 | -------------------------------------------------------------------------------- /app/services/redirect.server.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { describe, expect, it } from 'vitest' 10 | 11 | import { getUserRedirect } from './redirect.server' 12 | 13 | describe('Redirect Validation', () => { 14 | it('Allows redirects to the index', () => { 15 | expect(getUserRedirect('/')).toBe('/') 16 | }) 17 | 18 | it('Allows redirects to short RFD numbers', () => { 19 | expect(getUserRedirect('/rfd/1')).toBe('/rfd/1') 20 | }) 21 | 22 | it('Allows redirects to full RFD numbers', () => { 23 | expect(getUserRedirect('/rfd/0001')).toBe('/rfd/0001') 24 | }) 25 | 26 | it('Disallows redirects to RFD numbers with trailing slash', () => { 27 | expect(getUserRedirect('/rfd/0001/')).toBe('/') 28 | }) 29 | 30 | it('Disallows redirects to missing RFD numbers', () => { 31 | expect(getUserRedirect('/rfd/')).toBe('/') 32 | expect(getUserRedirect('/rfd')).toBe('/') 33 | }) 34 | 35 | it('Disallows redirects to invalid RFD numbers', () => { 36 | expect(getUserRedirect('/rfd/00001')).toBe('/') 37 | }) 38 | 39 | it('Disallows redirects to with query params', () => { 40 | expect(getUserRedirect('/?arg=value')).toBe('/') 41 | }) 42 | 43 | it('Disallows redirects to with RFD query params', () => { 44 | expect(getUserRedirect('/rfd/0001/?arg=value')).toBe('/') 45 | }) 46 | 47 | it('Disallows redirects to external urls', () => { 48 | expect(getUserRedirect('https://oxide.computer')).toBe('/') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /app/services/redirect.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // TODO: Can Remix do this logic for us? 10 | const RFD_PATH = /^\/rfd\/[0-9]{1,4}$/ 11 | 12 | /** 13 | * Return `path` if allowed, otherwise fall back to `'/'`. The only 14 | * user-controlled values we trust are the index or a specific RFD. 15 | */ 16 | export function getUserRedirect(path: string | null): string { 17 | return path && (path === '/' || RFD_PATH.test(path)) ? path : '/' 18 | } 19 | -------------------------------------------------------------------------------- /app/services/rfd.local.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import fs from 'fs' 10 | 11 | import { isTruthy } from '~/utils/isTruthy' 12 | import { parseRfdNum } from '~/utils/parseRfdNum' 13 | 14 | const localRepo = process.env.LOCAL_RFD_REPO 15 | 16 | export type LocalRfd = { 17 | number: number 18 | title: string 19 | state: string 20 | content: string 21 | committedAt: Date 22 | visibility: 'private' 23 | } 24 | 25 | export function isLocalMode(): boolean { 26 | return process.env.NODE_ENV === 'development' && !!localRepo 27 | } 28 | 29 | function findLineStartingWith(content: string, prefixRegex: string): string | undefined { 30 | // (^|\n) is required to match either the first line (beginning of file) or 31 | // subsequent lines 32 | return content.match(RegExp('(^|\n)' + prefixRegex + ' *([^\n]+)\n'))?.[2] 33 | } 34 | 35 | export function fetchLocalRfd(num: number): LocalRfd { 36 | try { 37 | const numStr = num.toString().padStart(4, '0') 38 | const buffer = fs.readFileSync(`${localRepo}/rfd/${numStr}/README.adoc`) 39 | const content = buffer.toString() 40 | 41 | // we used to parse the whole document for state and title, but this is 42 | // dramatically faster for live reload and seems to work fine 43 | const state = findLineStartingWith(content, ':state: ') || 'unknown' 44 | 45 | let title = findLineStartingWith(content, '= ') || 'Title Not Found' 46 | title = title.replace(`RFD ${parseInt(numStr)}`, '') 47 | 48 | return { 49 | number: num, 50 | title: title, 51 | state: state, 52 | content, 53 | committedAt: new Date(0), 54 | visibility: 'private', 55 | } 56 | } catch (e) { 57 | throw new Response('Not found', { status: 404 }) 58 | } 59 | } 60 | 61 | export function fetchLocalImage(num: number, src: string): Buffer | null { 62 | const numStr = num.toString().padStart(4, '0') 63 | const imagePath = `${localRepo}/rfd/${numStr}/${src}` 64 | try { 65 | return fs.readFileSync(imagePath) 66 | } catch (e) { 67 | console.error('Image not found', imagePath) 68 | return null 69 | } 70 | } 71 | 72 | export function fetchLocalRfds(): LocalRfd[] { 73 | const rfdDir = `${process.env.LOCAL_RFD_REPO}/rfd` 74 | 75 | const rfds = fs 76 | .readdirSync(rfdDir) 77 | .map((numStr) => { 78 | const num = parseRfdNum(numStr) 79 | if (!num) return null 80 | try { 81 | return fetchLocalRfd(num) // will throw on errors, hence the try/catch 82 | } catch { 83 | return null 84 | } 85 | }) 86 | .filter(isTruthy) 87 | .reverse() // sort by highest number first since we don't have dates 88 | 89 | return rfds 90 | } 91 | -------------------------------------------------------------------------------- /app/services/rfd.remote.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { 10 | AccessGroup_for_RfdPermission, 11 | Api, 12 | ApiResult, 13 | Job, 14 | RfdWithoutContent, 15 | RfdWithPdf, 16 | RfdWithRaw, 17 | SearchResults, 18 | SearchRfdsQueryParams, 19 | } from '@oxide/rfd.ts/client' 20 | import { ApiWithRetry } from '@oxide/rfd.ts/client-retry' 21 | 22 | import type { User } from './authn.server' 23 | 24 | export abstract class HttpError extends Error { 25 | public abstract readonly status: number 26 | } 27 | 28 | export class ApiError extends HttpError { 29 | public readonly status: number = 500 30 | } 31 | export class AuthenticationError extends Error { 32 | public readonly status: number = 401 33 | } 34 | export class AuthorizationError extends Error { 35 | public readonly status: number = 403 36 | } 37 | export class ClientError extends Error {} 38 | export class InvalidArgumentError extends Error { 39 | public readonly status: number = 400 40 | } 41 | export class UnauthenticatedError extends Error { 42 | constructor(message: string) { 43 | super(message) 44 | this.name = 'UnauthenticatedError' 45 | } 46 | } 47 | 48 | export function isHttpError(error: Error): error is HttpError { 49 | return !!(error as HttpError).status 50 | } 51 | 52 | export function getRfdApiUrl(): string { 53 | // If we are in local mode then we can simply return an empty value 54 | if (process.env.LOCAL_RFD_REPO) { 55 | return '' 56 | } 57 | 58 | // Otherwise crash the system if we do not have an API target set 59 | if (!process.env.RFD_API) { 60 | throw Error('Env var RFD_API must be set when not running in local mode') 61 | } 62 | 63 | return process.env.RFD_API 64 | } 65 | 66 | export function client(token?: string): Api { 67 | return new ApiWithRetry({ 68 | host: getRfdApiUrl(), 69 | token, 70 | baseParams: { 71 | headers: { Connection: 'keep-alive' }, 72 | }, 73 | }) 74 | } 75 | 76 | export async function fetchRemoteGroups( 77 | user: User | null, 78 | ): Promise<AccessGroup_for_RfdPermission[]> { 79 | return await getGroups(client(user?.token || undefined)) 80 | } 81 | export async function getGroups(rfdClient: Api): Promise<AccessGroup_for_RfdPermission[]> { 82 | const result = await rfdClient.methods.getGroups({}) 83 | return handleApiResponse(result) 84 | } 85 | 86 | export async function fetchRemoteRfd( 87 | num: number, 88 | user: User | null, 89 | ): Promise<RfdWithRaw | undefined> { 90 | const rfdClient = client(user?.token || undefined) 91 | return await getRemoteRfd(rfdClient, num) 92 | } 93 | export async function getRemoteRfd( 94 | rfdClient: Api, 95 | num: number, 96 | ): Promise<RfdWithRaw | undefined> { 97 | const result = await rfdClient.methods.viewRfd({ path: { number: num.toString() } }) 98 | 99 | if (result.response.status === 404) { 100 | return undefined 101 | } else { 102 | return handleApiResponse(result) 103 | } 104 | } 105 | 106 | export async function fetchRemoteRfdJobs(num: number, user: User | null): Promise<Job[]> { 107 | const rfdClient = client(user?.token || undefined) 108 | return await getRemoteRfdJobs(rfdClient, num) 109 | } 110 | export async function getRemoteRfdJobs(rfdClient: Api, num: number): Promise<Job[]> { 111 | const result = await rfdClient.methods.listJobs({ 112 | query: { rfd: num.toString(), limit: 20 }, 113 | }) 114 | return handleApiResponse(result) 115 | } 116 | 117 | export async function fetchRemoteRfdPdf( 118 | num: number, 119 | user: User | null, 120 | ): Promise<RfdWithPdf | undefined> { 121 | const rfdClient = client(user?.token || undefined) 122 | return await getRemoteRfdPdf(rfdClient, num) 123 | } 124 | export async function getRemoteRfdPdf( 125 | rfdClient: Api, 126 | num: number, 127 | ): Promise<RfdWithPdf | undefined> { 128 | const result = await rfdClient.methods.viewRfdPdf({ path: { number: num.toString() } }) 129 | 130 | if (result.response.status === 404) { 131 | return undefined 132 | } else { 133 | return handleApiResponse(result) 134 | } 135 | } 136 | 137 | export async function fetchRemoteRfds(user: User | null): Promise<RfdWithoutContent[]> { 138 | const rfdClient = client(user?.token || undefined) 139 | return await getRemoteRfds(rfdClient) 140 | } 141 | export async function getRemoteRfds(rfdClient: Api): Promise<RfdWithoutContent[]> { 142 | const result = await rfdClient.methods.listRfds({}) 143 | return handleApiResponse(result) 144 | } 145 | 146 | export async function searchRfds( 147 | user: User | null, 148 | params: IterableIterator<[string, string]>, 149 | ): Promise<SearchResults> { 150 | const rfdClient = client(user?.token || undefined) 151 | const query: SearchRfdsQueryParams = { q: '' } 152 | 153 | for (let [k, v] of params) { 154 | switch (k) { 155 | case 'q': 156 | query.q = v 157 | break 158 | case 'attributesToCrop': 159 | query.attributesToCrop = v 160 | break 161 | case 'highlightPreTag': 162 | query.highlightPreTag = v 163 | break 164 | case 'highlightPostTag': 165 | query.highlightPostTag = v 166 | break 167 | } 168 | } 169 | 170 | const result = await rfdClient.methods.searchRfds({ query }) 171 | return handleApiResponse(result) 172 | } 173 | 174 | function handleApiResponse<T>(response: ApiResult<T>): T { 175 | if (response.type === 'success') { 176 | return response.data 177 | } else if (response.type === 'client_error') { 178 | console.error('Failed attempting to send request to rfd server', response) 179 | throw response.error as ClientError 180 | } else { 181 | if (response.response.status === 401) { 182 | console.error('User is not authenticated', response) 183 | throw new AuthenticationError(response.data.message) 184 | } else if (response.response.status === 403) { 185 | console.error('User is not authorized', response) 186 | throw new AuthorizationError(response.data.message) 187 | } else { 188 | console.error('Request to rfd server failed', response) 189 | throw new ApiError(response.data.message) 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /app/services/session.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { createCookieSessionStorage } from '@remix-run/node' 10 | 11 | function getSecrets(): string[] { 12 | // Locally we don't do authentication, so a hard-coded secret is fine. I 13 | // generated a nice one anyway. See `isAuthenticated` in auth.server.ts. 14 | if (process.env.NODE_ENV !== 'production') return ['kVP73HUTzqw2bNGYZ.rPmV'] 15 | 16 | // in prod, crash if the session secret isn't defined 17 | if (!process.env.SESSION_SECRET) { 18 | throw Error('Env var SESSION_SECRET must be set in production') 19 | } 20 | 21 | return [process.env.SESSION_SECRET] 22 | } 23 | 24 | function sessionMaxAge(): number { 25 | if (process.env.SESSION_DURATION) { 26 | return parseInt(process.env.SESSION_DURATION) 27 | } 28 | 29 | return 60 * 60 * 24 * 14 // two weeks in seconds 30 | } 31 | 32 | export let sessionStorage = createCookieSessionStorage({ 33 | cookie: { 34 | name: '_session', 35 | sameSite: 'lax', // this helps with CSRF 36 | path: '/', // remember to add this so the cookie will work in all routes 37 | httpOnly: true, 38 | secrets: getSecrets(), 39 | secure: process.env.NODE_ENV === 'production', 40 | maxAge: sessionMaxAge(), 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /app/services/storage.server.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { describe, expect, it } from 'vitest' 10 | 11 | import { signUrl } from './storage.server' 12 | 13 | const expiration = 1665719939 14 | const keyName = 'key-name' 15 | const key = 'random-key-string' 16 | 17 | describe('Image url signing', () => { 18 | it('Handles simple filenames', () => { 19 | let url = 'https://oxide.computer/file.png' 20 | 21 | let signedUrl = signUrl(url, expiration, key, keyName) 22 | 23 | expect(signedUrl).toBe( 24 | 'https://oxide.computer/file.png?Expires=1665719939&KeyName=key-name&Signature=jAfxkbTs53ZhIWxq9G8qEXKyiRo', 25 | ) 26 | }) 27 | 28 | it('Generates the same url independent of source url encoding', () => { 29 | // Use both the non-encoded and the encoded form of this url to ensure that either one can be 30 | // run through the signing step to generate the same valid url 31 | let url = 'https://oxide.computer/file with spaces.png' 32 | 33 | // This is the result of running `url` through `encodeURI` 34 | let encodedUrl = 'https://oxide.computer/file%20with%20spaces.png' 35 | 36 | let signedUrl = signUrl(url, expiration, key, keyName) 37 | let signedEncodedUrl = signUrl(encodedUrl, expiration, key, keyName) 38 | 39 | // Verify that the baseline url was signed correctly and encoded 40 | expect(signedUrl).toBe( 41 | 'https://oxide.computer/file%20with%20spaces.png?Expires=1665719939&KeyName=key-name&Signature=kRxwhT3suBNu1giRQoVL_sabtVo', 42 | ) 43 | 44 | // Verify that both the baseline url and the pre-encode url result in the same signed url 45 | expect(signedUrl).toBe(signedEncodedUrl) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /app/services/storage.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { createHmac } from 'crypto' 10 | 11 | export function getExpiringUrl(path: string, ttlInSeconds: number): string { 12 | const expiration = Math.floor(Date.now() / 1000) + ttlInSeconds 13 | const storageUrl = process.env.STORAGE_URL 14 | const signingKey = process.env.STORAGE_KEY 15 | const signingKeyName = process.env.STORAGE_KEY_NAME 16 | const url = storageUrl + '/' + path 17 | 18 | return signUrl(url, expiration, signingKey, signingKeyName) 19 | } 20 | 21 | // The signing process is described by https://cloud.google.com/cdn/docs/using-signed-urls#programmatically_creating_signed_urls 22 | export function signUrl( 23 | url: string, 24 | expiration: number, 25 | signingKey: string | undefined, 26 | signingKeyName: string | undefined, 27 | ): string { 28 | if (!signingKey) { 29 | throw new Error('Unable to generate image urls without a SIGNING_KEY configured') 30 | } 31 | 32 | if (!signingKeyName) { 33 | throw new Error('Unable to generate image urls without a SIGNING_KEY_NAME configured') 34 | } 35 | 36 | let key = Buffer.from(signingKey, 'base64') 37 | let encodedUrl = encodeURI(decodeURI(url)) 38 | 39 | let urlToSign = `${encodedUrl}?Expires=${expiration}&KeyName=${signingKeyName}` 40 | let sig = createHmac('sha1', key).update(urlToSign).digest('base64') 41 | let cleanedSignature = sig.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') 42 | 43 | return `${urlToSign}&Signature=${cleanedSignature}` 44 | } 45 | -------------------------------------------------------------------------------- /app/styles/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | @import './lib/fonts.css'; 10 | @import '@oxide/design-system/styles/dist/main.css'; 11 | @import '@oxide/design-system/styles/dist/yellow.css'; 12 | @import '@oxide/design-system/styles/dist/purple.css'; 13 | @import '@oxide/design-system/styles/dist/green.css'; 14 | @import '@oxide/design-system/styles/dist/blue.css'; 15 | @import '@oxide/design-system/components/dist/asciidoc.css'; 16 | @import '@oxide/design-system/components/dist/button.css'; 17 | @import '@oxide/design-system/components/dist/spinner.css'; 18 | 19 | @import './lib/asciidoc.css'; 20 | @import './lib/highlight.css'; 21 | @import './lib/github-markdown.css'; 22 | @import './lib/loading-bar.css'; 23 | 24 | @tailwind base; 25 | @tailwind components; 26 | @tailwind utilities; 27 | 28 | :root { 29 | --toc-width: 240px; 30 | --header-height: 60px; 31 | } 32 | 33 | .light-mode { 34 | filter: invert(1) hue-rotate(180deg); 35 | } 36 | 37 | .light-mode img, 38 | .light-mode picture, 39 | .light-mode video { 40 | filter: invert(1) hue-rotate(180deg); 41 | } 42 | 43 | @media only screen and (max-width: 1200px) { 44 | :root { 45 | --toc-width: 200px; 46 | } 47 | } 48 | 49 | html, 50 | body { 51 | -webkit-font-smoothing: antialiased; 52 | font-feature-settings: 53 | 'ss02' on, 54 | 'ss03' on, 55 | 'ss09' on, 56 | 'ss06' on, 57 | 'ss07' on, 58 | 'ss08' on, 59 | 'case' on; 60 | -webkit-text-stroke: 0; 61 | font-size: 16px; 62 | 63 | @apply bg-default; 64 | } 65 | 66 | @layer base { 67 | body { 68 | /* leading override is gross, fix later */ 69 | @apply !leading-[1.33] text-sans-sm text-raise; 70 | } 71 | } 72 | 73 | .overlay-shadow { 74 | box-shadow: 0px 8px 50px 0px rgba(0, 0, 0, 0.5); 75 | } 76 | 77 | .backdrop { 78 | background-color: var(--surface-scrim); 79 | transition-property: opacity, backdrop-filter; 80 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 81 | transition-duration: 50ms; 82 | opacity: 1; 83 | backdrop-filter: blur(4px); 84 | } 85 | 86 | /** 87 | * Remove focus ring for non-explicit scenarios. 88 | */ 89 | a:focus-visible, 90 | button:focus-visible, 91 | [role='listbox']:focus-visible, 92 | [role='option']:focus-visible, 93 | [role='button']:focus-visible, 94 | input[type='radio']:focus-visible, 95 | input[type='checkbox']:focus-visible { 96 | @apply outline-0 ring-2 ring-accent-tertiary; 97 | } 98 | 99 | a:focus, 100 | button:focus, 101 | [role='listbox']:focus, 102 | [role='option']:focus, 103 | [role='button']:focus, 104 | input[type='radio']:focus, 105 | input[type='checkbox']:focus { 106 | outline: 2px solid black; 107 | outline-offset: -2px; 108 | box-shadow: none; 109 | @apply outline-0 ring-2 ring-accent-tertiary; 110 | } 111 | 112 | a:focus:not(:focus-visible), 113 | button:focus:not(:focus-visible), 114 | [role='listbox']:focus:not(:focus-visible), 115 | [role='option']:focus:not(:focus-visible), 116 | [role='button']:focus:not(:focus-visible), 117 | input[type='radio']:focus:not(:focus-visible), 118 | input[type='checkbox']:focus:not(:focus-visible) { 119 | @apply outline-0 ring-0; 120 | } 121 | 122 | .link-with-underline { 123 | @apply text-default hover:text-raise; 124 | text-decoration: underline; 125 | text-decoration-color: var(--content-quinary); 126 | 127 | &:hover { 128 | text-decoration-color: var(--content-tertiary); 129 | } 130 | } 131 | 132 | table.inline-table { 133 | @apply border-separate border-spacing-0; 134 | thead { 135 | @apply bg-tertiary; 136 | 137 | tr th { 138 | @apply h-8 border-y pr-4 text-left text-mono-sm border-default; 139 | } 140 | 141 | tr th:first-child { 142 | @apply rounded-bl rounded-tl border-l; 143 | } 144 | 145 | tr th:last-child { 146 | @apply rounded-br rounded-tr border-r; 147 | } 148 | } 149 | tbody { 150 | td { 151 | @apply h-10 border-b border-b-default; 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /app/styles/lib/asciidoc.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | @layer components { 10 | /* Removes top spacing from header if it is the first element */ 11 | .asciidoc-body #content > .sect1:first-of-type > h1:nth-child(2), 12 | .asciidoc-body #content > .sect1:first-of-type > h2:nth-child(2), 13 | .asciidoc-body #content > .sect1:first-of-type > h3:nth-child(2), 14 | .asciidoc-body #content > .sect1:first-of-type > h4:nth-child(2), 15 | .asciidoc-body #content > .sect1:first-of-type > h5:nth-child(2) { 16 | @apply mt-0; 17 | } 18 | 19 | .asciidoc-body h2[data-sectnum] a, 20 | .asciidoc-body h1[data-sectnum] a, 21 | .asciidoc-body h3[data-sectnum] a, 22 | .asciidoc-body h4[data-sectnum] a, 23 | .asciidoc-body h5[data-sectnum] a { 24 | @apply inline; 25 | } 26 | 27 | .asciidoc-body h1[data-sectnum]:before, 28 | .asciidoc-body h2[data-sectnum]:before, 29 | .asciidoc-body h3[data-sectnum]:before, 30 | .asciidoc-body h4[data-sectnum]:before, 31 | .asciidoc-body h5[data-sectnum]:before { 32 | @apply pointer-events-none top-[6px] mr-2 inline-block text-tertiary 800:absolute 800:-left-[72px] 800:mr-0 800:w-[60px] 800:text-right 800:text-sans-lg; 33 | content: attr(data-sectnum); 34 | } 35 | 36 | .asciidoc-body h3[data-sectnum]:before { 37 | @apply top-[2px]; 38 | } 39 | 40 | .asciidoc-body h4[data-sectnum]:before, 41 | .asciidoc-body h5[data-sectnum]:before { 42 | @apply top-0; 43 | } 44 | 45 | .asciidoc-body h1[data-sectnum] a:after, 46 | .asciidoc-body h2[data-sectnum] a:after, 47 | .asciidoc-body h3[data-sectnum] a:after, 48 | .asciidoc-body h4[data-sectnum] a:after, 49 | .asciidoc-body h5[data-sectnum] a:after { 50 | @apply hidden; 51 | } 52 | 53 | .asciidoc-body .table-wrapper caption a:hover:after { 54 | @apply align-[-2px]; 55 | } 56 | 57 | .asciidoc-body img { 58 | @apply bg-inverse-primary; 59 | } 60 | 61 | .asciidoc-body .transparent-dark img { 62 | @apply bg-raise; 63 | } 64 | 65 | .asciidoc-body span img { 66 | @apply bg-transparent; 67 | } 68 | 69 | .asciidoc-body .admonition-content > div:first-of-type { 70 | @apply text-sans-semi-md; 71 | } 72 | 73 | .asciidoc-body pre .conum[data-value] { 74 | @apply text-accent bg-accent-secondary-hover; 75 | } 76 | 77 | .asciidoc-body svg p { 78 | font-size: initial; 79 | letter-spacing: initial; 80 | font-weight: initial; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/styles/lib/fonts.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /* GT America Mono */ 10 | /* Source: https://www.grillitype.com/typeface/gt-america */ 11 | @font-face { 12 | font-family: 'GT America Mono'; 13 | src: 14 | url('/fonts/GT-America-Mono-Regular-OCC.woff2') format('woff2'), 15 | url('/fonts/GT-America-Mono-Regular-OCC.woff') format('woff'); 16 | font-weight: 400; 17 | font-style: normal; 18 | } 19 | 20 | @font-face { 21 | font-family: 'GT America Mono'; 22 | src: 23 | url('/fonts/GT-America-Mono-Medium.woff2') format('woff2'), 24 | url('/fonts/GT-America-Mono-Medium.woff') format('woff'); 25 | font-weight: 500; 26 | font-style: normal; 27 | } 28 | 29 | /* Suisse International */ 30 | /* Source: https://www.swisstypefaces.com/fonts/suisse/ */ 31 | @font-face { 32 | font-family: 'SuisseIntl'; 33 | src: 34 | url('/fonts/SuisseIntl-Light-WebS.woff2') format('woff2'), 35 | url('/fonts/SuisseIntl-Light-WebS.woff') format('woff'); 36 | font-weight: 300; 37 | font-style: normal; 38 | } 39 | 40 | @font-face { 41 | font-family: 'SuisseIntl'; 42 | src: 43 | url('/fonts/SuisseIntl-Regular-WebS.woff2') format('woff2'), 44 | url('/fonts/SuisseIntl-Regular-WebS.woff') format('woff'); 45 | font-weight: 400; 46 | font-style: normal; 47 | } 48 | 49 | @font-face { 50 | font-family: 'SuisseIntl'; 51 | src: 52 | url('/fonts/SuisseIntl-RegularItalic-WebS.woff2') format('woff2'), 53 | url('/fonts/SuisseIntl-RegularItalic-WebS.woff') format('woff'); 54 | font-weight: 400; 55 | font-style: italic; 56 | } 57 | 58 | @font-face { 59 | font-family: 'SuisseIntl'; 60 | src: 61 | url('/fonts/SuisseIntl-Medium-WebS.woff2') format('woff2'), 62 | url('/fonts/SuisseIntl-Medium-WebS.woff') format('woff'); 63 | font-weight: 500; 64 | font-style: normal; 65 | } 66 | -------------------------------------------------------------------------------- /app/styles/lib/github-markdown.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .github-markdown.asciidoc-body { 10 | & p { 11 | @apply mb-2 text-sans-md; 12 | } 13 | 14 | & *:last-child { 15 | @apply mb-0; 16 | } 17 | 18 | & ul, 19 | & ol { 20 | @apply normal-case text-sans-md text-default; 21 | } 22 | 23 | & blockquote { 24 | @apply mb-4 mt-4 border-l pl-4 border-default; 25 | 26 | & p { 27 | @apply text-tertiary; 28 | } 29 | } 30 | 31 | & ins { 32 | @apply rounded-sm text-accent; 33 | background-color: rgba(var(--base-green-800-rgb), 0.2); 34 | } 35 | 36 | & del { 37 | @apply rounded-sm text-destructive; 38 | background-color: rgba(var(--base-red-800-rgb), 0.2); 39 | text-decoration: line-through; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/styles/lib/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .hljs { 10 | color: #babcbd; 11 | /* background: #080f11; */ 12 | } 13 | 14 | .hljs::selection, 15 | .hljs::selection { 16 | color: #1e1e22; 17 | background: #bf8fef; 18 | } 19 | 20 | .hljs-code, 21 | .hljs-comment, 22 | .hljs-quote { 23 | color: #888896; 24 | } 25 | 26 | .hljs-deletion, 27 | .hljs-literal, 28 | .hljs-number { 29 | color: #e87e97; 30 | } 31 | 32 | .hljs-doctag, 33 | .hljs-meta, 34 | .hljs-operator, 35 | .hljs-punctuation, 36 | .hljs-selector-attr, 37 | .hljs-subst, 38 | .hljs-template-variable { 39 | color: #f1d78f; 40 | } 41 | 42 | .hljs-type { 43 | color: #efef8f; 44 | } 45 | 46 | .hljs-selector-class, 47 | .hljs-selector-id, 48 | .hljs-tag, 49 | .hljs-title { 50 | color: #48d597; 51 | } 52 | 53 | .hljs-addition, 54 | .hljs-regexp, 55 | .hljs-string { 56 | color: #48d597; 57 | } 58 | 59 | .hljs-class, 60 | .hljs-property { 61 | color: #8fefbf; 62 | } 63 | 64 | .hljs-name, 65 | .hljs-selector-tag { 66 | color: #48d597; 67 | } 68 | 69 | .hljs-built_in, 70 | .hljs-keyword { 71 | color: #7996dd; 72 | } 73 | 74 | .hljs-bullet, 75 | .hljs-section { 76 | color: #8f8fef; 77 | } 78 | 79 | .hljs-selector-pseudo { 80 | color: #2e8160; 81 | } 82 | 83 | .hljs-attr, 84 | .hljs-attribute, 85 | .hljs-params, 86 | .hljs-variable { 87 | color: #a2dfc5; 88 | } 89 | 90 | .hljs-link, 91 | .hljs-symbol { 92 | color: #7e89e8; 93 | } 94 | 95 | .hljs-literal, 96 | .hljs-strong, 97 | .hljs-title { 98 | font-weight: 700; 99 | } 100 | 101 | .hljs-emphasis { 102 | font-style: italic; 103 | } 104 | 105 | code.language-bash:before { 106 | content: '$ '; 107 | /* @apply text-grey-700; */ 108 | } 109 | -------------------------------------------------------------------------------- /app/styles/lib/loading-bar.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | .global-loading-bar { 10 | opacity: 1; 11 | width: 0%; 12 | } 13 | 14 | .global-loading-bar.loading { 15 | animation-name: loading-bar-loading; 16 | animation-duration: 4s; 17 | /* don't reset to zero at 100%, just sit there */ 18 | animation-fill-mode: forwards; 19 | } 20 | 21 | .global-loading-bar.done { 22 | animation-name: loading-bar-done; 23 | animation-duration: 0.3s; 24 | } 25 | 26 | @keyframes loading-bar-loading { 27 | 0% { 28 | opacity: 1; 29 | width: 0%; 30 | } 31 | 32 | 4% { 33 | opacity: 1; 34 | width: 20%; 35 | } 36 | 37 | 40% { 38 | opacity: 1; 39 | width: 40%; 40 | } 41 | 42 | 100% { 43 | opacity: 1; 44 | width: 45%; 45 | } 46 | } 47 | 48 | @keyframes loading-bar-done { 49 | 0% { 50 | opacity: 1; 51 | width: 45%; 52 | } 53 | 54 | 100% { 55 | opacity: 0; 56 | width: 100%; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/utils/array.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /* eslint-disable @typescript-eslint/no-explicit-any */ 10 | const identity = (x: any) => x 11 | 12 | /** Returns a new array sorted by `by`. Assumes return value of `by` is 13 | * comparable. Default value of `by` is the identity function. */ 14 | export function sortBy<T>(arr: T[], by: (t: T) => any = identity) { 15 | const copy = [...arr] 16 | copy.sort((a, b) => (by(a) < by(b) ? -1 : by(a) > by(b) ? 1 : 0)) 17 | return copy 18 | } 19 | /* eslint-enable @typescript-eslint/no-explicit-any */ 20 | -------------------------------------------------------------------------------- /app/utils/asciidoctor.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { type Block, type Html5Converter } from '@asciidoctor/core' 9 | import { InlineConverter, loadAsciidoctor } from '@oxide/design-system/components/dist' 10 | import { renderToString } from 'react-dom/server' 11 | 12 | import { InlineImage } from '~/components/AsciidocBlocks/Image' 13 | 14 | const attrs = { 15 | sectlinks: 'true', 16 | stem: 'latexmath', 17 | stylesheet: false, 18 | } 19 | 20 | const ad = loadAsciidoctor({}) 21 | 22 | class CustomInlineConverter { 23 | baseConverter: Html5Converter 24 | 25 | constructor() { 26 | this.baseConverter = new InlineConverter() 27 | } 28 | 29 | convert(node: Block, transform: string) { 30 | switch (node.getNodeName()) { 31 | case 'inline_image': 32 | return renderToString(<InlineImage node={node} />) 33 | case 'image': 34 | return renderToString(<InlineImage node={node} />) 35 | default: 36 | break 37 | } 38 | 39 | return this.baseConverter.convert(node, transform) 40 | } 41 | } 42 | 43 | ad.ConverterFactory.register(new CustomInlineConverter(), ['html5']) 44 | 45 | export { ad, attrs } 46 | -------------------------------------------------------------------------------- /app/utils/classed.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import cn from 'classnames' 10 | import React from 'react' 11 | 12 | // all the cuteness of tw.span`text-green-500 uppercase` with zero magic 13 | 14 | const make = 15 | <T extends keyof JSX.IntrinsicElements>(tag: T) => 16 | // only one argument here means string interpolations are not allowed 17 | (strings: TemplateStringsArray) => { 18 | const Comp = ({ className, children, ...rest }: JSX.IntrinsicElements[T]) => 19 | React.createElement(tag, { className: cn(strings[0], className), ...rest }, children) 20 | Comp.displayName = `classed.${tag}` 21 | return Comp 22 | } 23 | 24 | // JSX.IntrinsicElements[T] ensures same props as the real DOM element. For example, 25 | // classed.span doesn't allow a type attr but classed.input does. 26 | 27 | export const classed = { 28 | button: make('button'), 29 | div: make('div'), 30 | footer: make('footer'), 31 | h1: make('h1'), 32 | h2: make('h2'), 33 | h3: make('h3'), 34 | h4: make('h4'), 35 | hr: make('hr'), 36 | header: make('header'), 37 | input: make('input'), 38 | label: make('label'), 39 | li: make('li'), 40 | main: make('main'), 41 | nav: make('nav'), 42 | ol: make('ol'), 43 | p: make('p'), 44 | span: make('span'), 45 | table: make('table'), 46 | tbody: make('tbody'), 47 | td: make('td'), 48 | th: make('th'), 49 | tr: make('tr'), 50 | } as const 51 | 52 | // result: classed.button`text-green-500 uppercase` returns a component with those classes 53 | -------------------------------------------------------------------------------- /app/utils/fuzz.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import uFuzzy from '@leeoniya/ufuzzy' 10 | 11 | export const fuzzConf: uFuzzy.Options = { 12 | intraMode: 1, 13 | intraIns: 1, // Max number of extra chars allowed between each char within a term 14 | // ↓ Error types to tolerate within terms 15 | intraSub: 1, 16 | intraTrn: 1, 17 | intraDel: 1, 18 | } 19 | 20 | export const fuzz = new uFuzzy(fuzzConf) 21 | -------------------------------------------------------------------------------- /app/utils/isTruthy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T 10 | 11 | /** 12 | * TS-friendly version of `Boolean` for when you want to filter for truthy 13 | * values. Use `.filter(isTruthy)` instead of `.filter(Boolean)`. See 14 | * [StackOverflow](https://stackoverflow.com/a/58110124/604986). 15 | */ 16 | export function isTruthy<T>(value: T): value is Truthy<T> { 17 | return !!value 18 | } 19 | -------------------------------------------------------------------------------- /app/utils/parseRfdNum.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { expect, test } from 'vitest' 10 | 11 | import { parseRfdNum } from './parseRfdNum' 12 | 13 | test.each([ 14 | ['1', 1], 15 | ['01', 1], 16 | ['001', 1], 17 | ['0001', 1], 18 | ['0321', 321], 19 | ['9999', 9999], 20 | ['00001', null], 21 | ['../', null], 22 | ['abc', null], 23 | [' 5', null], 24 | ])('parseRfdNum', (input: string, result: number | null) => { 25 | expect(parseRfdNum(input)).toEqual(result) 26 | }) 27 | -------------------------------------------------------------------------------- /app/utils/parseRfdNum.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | /** only match and parse 1-4 digit numbers */ 10 | export const parseRfdNum = (s: string | undefined): number | null => 11 | s && /^[0-9]{1,4}$/.test(s) ? parseInt(s, 10) : null 12 | 13 | /** only match and parse 1-6 digit numbers */ 14 | export const parsePullNum = (s: string | undefined): number | null => 15 | s && /^[0-9]{1,6}$/.test(s) ? parseInt(s, 10) : null 16 | -------------------------------------------------------------------------------- /app/utils/permission.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { describe, expect, it } from 'vitest' 10 | 11 | import type { Group } from '~/services/authn.server' 12 | 13 | import { can } from './permission' 14 | 15 | describe('Group Permissions', () => { 16 | it('Validates group has non value permission', () => { 17 | const group: Group = { 18 | id: 'test', 19 | name: 'Test', 20 | permissions: ['GetRfdsAll'], 21 | } 22 | 23 | expect(can(group.permissions, { GetRfd: 123 })).toBe(true) 24 | }) 25 | 26 | it('Validates group has simple value permission', () => { 27 | const group: Group = { 28 | id: 'test', 29 | name: 'Test', 30 | permissions: [{ GetRfd: 123 }], 31 | } 32 | 33 | expect(can(group.permissions, { GetRfd: 123 })).toBe(true) 34 | }) 35 | 36 | it('Validates group has list value permission', () => { 37 | const group: Group = { 38 | id: 'test', 39 | name: 'Test', 40 | permissions: [{ GetRfds: [123] }], 41 | } 42 | 43 | expect(can(group.permissions, { GetRfd: 123 })).toBe(true) 44 | }) 45 | 46 | it('Validates group with multiple permissions', () => { 47 | const group: Group = { 48 | id: 'test', 49 | name: 'Test', 50 | permissions: [{ GetRfd: 123 }, { GetRfds: [123] }, 'GetRfdsAll'], 51 | } 52 | 53 | expect(can(group.permissions, { GetRfd: 123 })).toBe(true) 54 | }) 55 | 56 | it('Validates mapped group permission', () => { 57 | const group: Group = { 58 | id: 'test', 59 | name: 'Test', 60 | permissions: ['GetDiscussionsAll'], 61 | } 62 | 63 | expect(can(group.permissions, 'GetDiscussionsAll')).toBe(true) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /app/utils/permission.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { RfdPermission } from '@oxide/rfd.ts/client' 10 | 11 | export function can(allPermissions: RfdPermission[], permission: RfdPermission): boolean { 12 | const checks = createChecks(permission) 13 | const allowed = checks.some((check) => performCheck(allPermissions, check)) 14 | 15 | return allowed 16 | } 17 | 18 | export function any( 19 | allPermissions: RfdPermission[], 20 | permissions: RfdPermission[], 21 | ): boolean { 22 | return permissions.some((permission) => can(allPermissions, permission)) 23 | } 24 | 25 | function createChecks(permission: RfdPermission): RfdPermission[] { 26 | const checks: RfdPermission[] = [] 27 | checks.push(permission) 28 | 29 | if (typeof permission === 'string') { 30 | switch (permission) { 31 | case 'GetDiscussionsAll': 32 | checks.push('GetDiscussionsAll') 33 | break 34 | } 35 | } else if (typeof permission === 'object') { 36 | const key = Object.keys(permission)[0] 37 | switch (key) { 38 | case 'GetDiscussion': 39 | checks.push({ GetDiscussions: [Object.values(permission)[0]] }) 40 | checks.push('GetDiscussionsAll') 41 | break 42 | case 'GetDiscussions': 43 | checks.push('GetDiscussionsAll') 44 | break 45 | case 'GetRfd': 46 | checks.push({ GetRfds: [Object.values(permission)[0]] }) 47 | checks.push('GetRfdsAll') 48 | break 49 | case 'GetRfds': 50 | checks.push('GetRfdsAll') 51 | break 52 | } 53 | } 54 | 55 | return checks 56 | } 57 | 58 | function performCheck(permissions: RfdPermission[], check: RfdPermission): boolean { 59 | return ( 60 | simplePermissionCheck(permissions, check) || listPermissionCheck(permissions, check) 61 | ) 62 | } 63 | 64 | function simplePermissionCheck( 65 | permissions: RfdPermission[], 66 | check: RfdPermission, 67 | ): boolean { 68 | return permissions.some((p) => { 69 | switch (typeof p) { 70 | case 'string': 71 | return p === check 72 | case 'object': 73 | return ( 74 | Object.keys(p)[0] === Object.keys(check)[0] && 75 | permissionValue(p) === permissionValue(check) 76 | ) 77 | default: 78 | return false 79 | } 80 | }) 81 | } 82 | 83 | function listPermissionCheck(permissions: RfdPermission[], check: RfdPermission): boolean { 84 | return permissions.some((p) => { 85 | switch (typeof p) { 86 | case 'string': 87 | return false 88 | case 'object': 89 | if (Object.keys(p)[0] === Object.keys(check)[0]) { 90 | const existing = permissionValue(p) 91 | const expected = permissionValue(check) 92 | 93 | return ( 94 | Array.isArray(existing) && 95 | Array.isArray(expected) && 96 | expected.every((value) => existing.includes(value)) 97 | ) 98 | } else { 99 | return false 100 | } 101 | default: 102 | return false 103 | } 104 | }) 105 | } 106 | 107 | function permissionValue(permission: RfdPermission): any[] | undefined { 108 | switch (typeof permission) { 109 | case 'string': 110 | return undefined 111 | case 'object': 112 | return Object.values(permission)[0] 113 | default: 114 | return undefined 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/utils/rfdApi.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | export type RfdScope = 10 | | 'user:info:r' 11 | | 'user:info:w' 12 | | 'user:provider:w' 13 | | 'user:token:r' 14 | | 'user:token:w' 15 | | 'group:info:r' 16 | | 'group:info:w' 17 | | 'group:membership:w' 18 | | 'mapper:r' 19 | | 'mapper:w' 20 | | 'rfd:content:r' 21 | | 'rfd:discussion:r' 22 | | 'search' 23 | | 'oauth:client:r' 24 | | 'oauth:client:w' 25 | | 'mlink:client:r' 26 | | 'mlink:client:w' 27 | 28 | export type RfdApiProvider = 'google' | 'github' 29 | 30 | // This is an incomplete type, it contains only the permission types that we need to perform 31 | // internal checks against 32 | export type RfdApiPermission = 33 | | 'GetDiscussionsAll' 34 | | { GetRfd: number } 35 | | { GetRfds: number[] } 36 | | 'GetRfdsAll' 37 | | 'SearchRfds' 38 | 39 | export type RfdResponse = { 40 | id: string 41 | rfd_number: number 42 | link: string | null 43 | discussion: string | null 44 | title: string 45 | state: string 46 | authors: string 47 | labels: string 48 | content: string 49 | sha: string 50 | commit: string 51 | committed_at: string 52 | pdfs: { 53 | source: string 54 | link: string 55 | }[] 56 | visibility: 'private' | 'public' 57 | } 58 | 59 | export type RfdListResponseItem = { 60 | id: string 61 | rfd_number: number 62 | link: string | null 63 | discussion: string | null 64 | title: string 65 | state: string 66 | authors: string 67 | labels: string 68 | sha: string 69 | commit: string 70 | committed_at: string 71 | visibility: 'private' | 'public' 72 | } 73 | 74 | export type GroupResponse = { 75 | id: string 76 | name: string 77 | permissions: RfdApiPermission[] 78 | created_at: string 79 | updated_at: string 80 | deleted_at: string 81 | } 82 | -------------------------------------------------------------------------------- /app/utils/rfdApiStrategy.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import type { RfdPermission } from '@oxide/rfd.ts/client' 10 | import { redirect, type SessionData, type SessionStorage } from '@remix-run/node' 11 | import type { AuthenticateOptions, StrategyVerifyCallback } from 'remix-auth' 12 | import { 13 | OAuth2Strategy, 14 | type OAuth2Profile, 15 | type OAuth2StrategyVerifyParams, 16 | } from 'remix-auth-oauth2' 17 | 18 | import type { RfdApiProvider, RfdScope } from './rfdApi' 19 | 20 | export type RfdApiStrategyOptions = { 21 | host: string 22 | clientID: string 23 | clientSecret: string 24 | callbackURL: string 25 | remoteProvider: RfdApiProvider 26 | /** 27 | * @default "rfd:content:r rfd:discussion:r search user:info:r" 28 | */ 29 | scope?: RfdScope[] | string 30 | } 31 | 32 | export type RfdApiAccessToken = { 33 | iss: string 34 | aud: string 35 | sub: string 36 | prv: string 37 | scp: string[] 38 | exp: number 39 | nbf: number 40 | jti: string 41 | } 42 | 43 | type RfdApiProfileResponse = { 44 | info: { 45 | id: string 46 | groups: string[] 47 | permissions: RfdPermission[] 48 | created_at: string 49 | } 50 | providers: { 51 | id: string 52 | api_user_id: string 53 | provider: string 54 | provider_id: string 55 | emails: string[] 56 | display_names: string[] 57 | created_at: string 58 | updated_at: string 59 | }[] 60 | } 61 | 62 | export type ExpiringUser = { 63 | expiresAt: number 64 | } 65 | 66 | export type RfdApiProfile = { 67 | _raw: RfdApiProfileResponse 68 | } & OAuth2Profile 69 | 70 | export type RfdApiExtraParams = { 71 | token_type: string 72 | expires_in: number 73 | } & Record<string, string | number> 74 | 75 | export const RfdApiStrategyScopeSeparator = ' ' 76 | export const RfdApiStrategyDefaultScopes = [ 77 | 'rfd:content:r', 78 | 'rfd:discussion:r', 79 | 'search', 80 | 'user:info:r', 81 | ].join(RfdApiStrategyScopeSeparator) 82 | export const RfdApiStrategyDefaultName = 'rfd-api' 83 | 84 | export class RfdApiStrategy<User extends ExpiringUser> extends OAuth2Strategy< 85 | User, 86 | RfdApiProfile, 87 | RfdApiExtraParams 88 | > { 89 | public name = `rfd-api` 90 | protected userInfoUrl = `` 91 | 92 | constructor( 93 | { 94 | host, 95 | clientID, 96 | clientSecret, 97 | callbackURL, 98 | remoteProvider, 99 | scope, 100 | }: RfdApiStrategyOptions, 101 | verify: StrategyVerifyCallback< 102 | User, 103 | OAuth2StrategyVerifyParams<RfdApiProfile, RfdApiExtraParams> 104 | >, 105 | ) { 106 | super( 107 | { 108 | clientID, 109 | clientSecret, 110 | callbackURL, 111 | authorizationURL: `${host}/login/oauth/${remoteProvider}/code/authorize`, 112 | tokenURL: `${host}/login/oauth/${remoteProvider}/code/token`, 113 | }, 114 | verify, 115 | ) 116 | this.name = `${this.name}-${remoteProvider}` 117 | this.scope = this.parseScope(scope) 118 | this.userInfoUrl = `${host}/self` 119 | } 120 | 121 | protected authorizationParams(): URLSearchParams { 122 | const params = new URLSearchParams() 123 | return params 124 | } 125 | 126 | protected async userProfile(accessToken: string): Promise<RfdApiProfile> { 127 | const response = await fetch(this.userInfoUrl, { 128 | headers: { 129 | Authorization: `Bearer ${accessToken}`, 130 | }, 131 | }) 132 | 133 | const body: RfdApiProfileResponse = await response.json() 134 | const emails = [...new Set(body.providers.flatMap((provider) => provider.emails))] 135 | const displayNames = [ 136 | ...new Set(body.providers.flatMap((provider) => provider.display_names)), 137 | ] 138 | 139 | const profile: RfdApiProfile = { 140 | provider: 'rfd-api', 141 | id: body.info.id, 142 | emails: emails.map((email) => ({ value: email })), 143 | displayName: displayNames[0], 144 | _raw: body, 145 | } 146 | 147 | return profile 148 | } 149 | 150 | // Allow users the option to pass a scope string, or typed array 151 | private parseScope(scope: RfdApiStrategyOptions['scope']) { 152 | if (!scope) { 153 | return RfdApiStrategyDefaultScopes 154 | } else if (Array.isArray(scope)) { 155 | return scope.join(RfdApiStrategyScopeSeparator) 156 | } 157 | 158 | return scope 159 | } 160 | 161 | protected async success( 162 | user: User, 163 | request: Request, 164 | sessionStorage: SessionStorage<SessionData, SessionData>, 165 | options: AuthenticateOptions, 166 | ): Promise<User> { 167 | // if a successRedirect is not set, we return the user 168 | if (!options.successRedirect) return user 169 | 170 | let session = await sessionStorage.getSession(request.headers.get('Cookie')) 171 | 172 | // if we do have a successRedirect, we redirect to it and set the user 173 | // in the session sessionKey 174 | session.set(options.sessionKey, user) 175 | session.set(options.sessionStrategyKey, options.name ?? this.name) 176 | throw redirect(options.successRedirect, { 177 | headers: { 178 | 'Set-Cookie': await sessionStorage.commitSession(session, { 179 | // expiresAt is the point in time (in seconds) at which the user's session expires. We need 180 | // to turn this in to the number of seconds that this session is valid for. We also always 181 | // want the session token to expire before the user's api token expires 182 | maxAge: user.expiresAt - Date.now() / 1000 - 60, 183 | }), 184 | }, 185 | }) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /app/utils/rfdSortOrder.server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | // mostly a separate module to keep zod on the server 10 | import { z } from 'zod' 11 | 12 | // separated so we can get the type out 13 | const sortAttrSchema = z.enum(['number', 'updated']) 14 | export type SortAttr = z.infer<typeof sortAttrSchema> 15 | 16 | const sortOrderSchema = z.object({ 17 | sortAttr: sortAttrSchema, 18 | sortDir: z.enum(['asc', 'desc']), 19 | }) 20 | 21 | type SortOrder = z.infer<typeof sortOrderSchema> 22 | 23 | export function parseSortOrder(obj: unknown): SortOrder { 24 | const parsed = sortOrderSchema.safeParse(obj) 25 | return parsed.success ? parsed.data : { sortAttr: 'updated', sortDir: 'desc' } 26 | } 27 | -------------------------------------------------------------------------------- /app/utils/titleCase.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | const titleCase = (text: string): string => { 10 | return text.replace( 11 | /\w\S*/g, 12 | (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(), 13 | ) 14 | } 15 | 16 | export default titleCase 17 | -------------------------------------------------------------------------------- /app/utils/trackEvent.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | export const serverTrackEvent = (event: string, url: string, referrer: string) => { 10 | trackEvent(event, url, referrer) 11 | } 12 | 13 | export const clientTrackEvent = (event: string) => { 14 | trackEvent(event, window.location.href, document.referrer, window.innerWidth) 15 | } 16 | 17 | export const trackEvent = ( 18 | event: string, 19 | url: string, 20 | referrer: string, 21 | screenWidth?: number, 22 | ) => { 23 | // Do not track outside of prod 24 | if (process.env.NODE_ENV !== 'production') { 25 | return 26 | } 27 | 28 | const baseUrl = new URL(url).origin 29 | 30 | fetch(`${baseUrl}/api/event`, { 31 | method: 'POST', 32 | body: JSON.stringify({ 33 | name: event, 34 | domain: 'oxide.computer', 35 | url: url, 36 | referrer: referrer, 37 | screen_width: screenWidth, 38 | }), 39 | }).catch((err) => { 40 | console.log(err) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "type": "module", 5 | "scripts": { 6 | "build": "remix vite:build", 7 | "dev": "remix vite:dev", 8 | "test": "vitest --config test/vitest.config.ts", 9 | "tsc": "tsc --noEmit", 10 | "fmt": "prettier --write --ignore-path ./.gitignore .", 11 | "fmt:check": "prettier --check --ignore-path ./.gitignore . ", 12 | "e2ec": "npx playwright test --project=chrome", 13 | "lint": "eslint app" 14 | }, 15 | "dependencies": { 16 | "@ariakit/react": "^0.3.5", 17 | "@asciidoctor/core": "^3.0.4", 18 | "@floating-ui/react": "^0.17.0", 19 | "@leeoniya/ufuzzy": "^1.0.18", 20 | "@meilisearch/instant-meilisearch": "^0.8.2", 21 | "@oxide/design-system": "^2.2.4", 22 | "@oxide/react-asciidoc": "^1.1.3", 23 | "@oxide/rfd.ts": "^0.1.3", 24 | "@radix-ui/react-accordion": "^1.1.2", 25 | "@radix-ui/react-dropdown-menu": "^2.0.4", 26 | "@remix-run/node": "2.15.2", 27 | "@remix-run/react": "^2.15.2", 28 | "@remix-run/serve": "2.15.2", 29 | "@tanstack/react-query": "^4.3.9", 30 | "@vercel/remix": "^2.15.2", 31 | "classnames": "^2.3.1", 32 | "dayjs": "^1.11.7", 33 | "highlight.js": "^11.6.0", 34 | "html-entities": "^2.4.0", 35 | "isbot": "^4", 36 | "lodash": "^4.17.21", 37 | "marked": "^4.2.5", 38 | "mermaid": "^11.4.1", 39 | "mime-types": "^2.1.35", 40 | "mousetrap": "^1.6.5", 41 | "octokit": "^3.1.2", 42 | "react": "^18.2.0", 43 | "react-dom": "^18.2.0", 44 | "react-instantsearch": "^7.13.4", 45 | "remeda": "^2.17.4", 46 | "remix-auth": "^3.6.0", 47 | "remix-auth-oauth2": "^1.11.1", 48 | "shiki": "^1.23.1", 49 | "simple-text-diff": "^1.7.0", 50 | "tunnel-rat": "^0.1.2", 51 | "zod": "^3.22.3" 52 | }, 53 | "devDependencies": { 54 | "@csstools/postcss-global-data": "^1.0.2", 55 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 56 | "@playwright/test": "^1.37.1", 57 | "@remix-run/dev": "2.15.2", 58 | "@remix-run/eslint-config": "2.15.2", 59 | "@tailwindcss/line-clamp": "^0.4.2", 60 | "@types/d3": "^7.4.0", 61 | "@types/express": "^4.17.14", 62 | "@types/js-cookie": "^3.0.2", 63 | "@types/jsdom": "^21.1.1", 64 | "@types/lodash": "^4.14.191", 65 | "@types/marked": "^4.0.8", 66 | "@types/mime-types": "^2.1.1", 67 | "@types/mousetrap": "^1.6.9", 68 | "@types/react": "^18.2.17", 69 | "@types/react-dom": "^18.0.6", 70 | "@types/react-instantsearch-dom": "^6.12.3", 71 | "autoprefixer": "^10.4.8", 72 | "cssnano": "^5.1.9", 73 | "eslint": "^8.20.0", 74 | "instantsearch.js": "^4.46.2", 75 | "postcss": "^8.4.31", 76 | "postcss-import": "^14.1.0", 77 | "postcss-nesting": "^10.1.4", 78 | "postcss-value-parser": "^4.2.0", 79 | "prettier-plugin-tailwindcss": "^0.5.7", 80 | "tailwindcss": "^3.3.1", 81 | "typescript": "~5.2.2", 82 | "vite": "^5.4.9", 83 | "vite-tsconfig-paths": "^5.0.1", 84 | "vitest": "^2.0.3" 85 | }, 86 | "engines": { 87 | "node": "^22.x" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { defineConfig, devices } from '@playwright/test' 10 | 11 | // in CI, we run the tests against the Vercel preview at BASE_URL 12 | 13 | // https://playwright.dev/docs/test-configuration 14 | export default defineConfig({ 15 | testDir: './test/e2e', 16 | fullyParallel: true, 17 | forbidOnly: !!process.env.CI, 18 | retries: process.env.CI ? 2 : 0, 19 | workers: process.env.CI ? 1 : undefined, 20 | timeout: 60000, 21 | use: { 22 | baseURL: process.env.CI ? process.env.BASE_URL : 'http://localhost:3000', 23 | trace: 'on-first-retry', 24 | }, 25 | projects: [ 26 | { name: 'chrome', use: { ...devices['Desktop Chrome'] } }, 27 | { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, 28 | { name: 'safari', use: { ...devices['Desktop Safari'] } }, 29 | ], 30 | webServer: process.env.CI 31 | ? undefined 32 | : { 33 | command: 'npm run dev', 34 | port: 3000, 35 | // This turns out to be very useful locally because starting up the dev 36 | // server takes like 8 seconds. If you already have it running, the tests 37 | // run in only a few seconds. 38 | reuseExistingServer: true, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | export default { 10 | plugins: { 11 | 'tailwindcss/nesting': {}, 12 | tailwindcss: {}, 13 | // use `npx autoprefixer --info` to see autoprefixer debug info 14 | autoprefixer: {}, 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /public/favicon-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/favicon-large.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/favicon.png -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | <svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M819.2 0H204.8C91.6921 0 0 91.6921 0 204.8V819.2C0 932.308 91.6921 1024 204.8 1024H819.2C932.308 1024 1024 932.308 1024 819.2V204.8C1024 91.6921 932.308 0 819.2 0Z" fill="#48D597"/> 3 | <path d="M669.5 152H242V872H782V266.545L669.5 152ZM332 512H557V602H332V512ZM602 737H332V647H602V737ZM647 471.091H332V377H647V471.091Z" fill="#080F11"/> 4 | </svg> 5 | -------------------------------------------------------------------------------- /public/fonts/GT-America-Mono-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/GT-America-Mono-Medium.woff -------------------------------------------------------------------------------- /public/fonts/GT-America-Mono-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/GT-America-Mono-Medium.woff2 -------------------------------------------------------------------------------- /public/fonts/GT-America-Mono-Regular-OCC.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/GT-America-Mono-Regular-OCC.woff -------------------------------------------------------------------------------- /public/fonts/GT-America-Mono-Regular-OCC.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/GT-America-Mono-Regular-OCC.woff2 -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Book-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Book-WebS.woff -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Book-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Book-WebS.woff2 -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Light-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Light-WebS.woff -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Light-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Light-WebS.woff2 -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Medium-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Medium-WebS.woff -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Medium-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Medium-WebS.woff2 -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Regular-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Regular-WebS.woff -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-Regular-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-Regular-WebS.woff2 -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-RegularItalic-WebS.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-RegularItalic-WebS.woff -------------------------------------------------------------------------------- /public/fonts/SuisseIntl-RegularItalic-WebS.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/fonts/SuisseIntl-RegularItalic-WebS.woff2 -------------------------------------------------------------------------------- /public/img/header-grid-mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oxidecomputer/rfd-site/29d2e49c9a81ffc7103c51e432ade0ea6d0dddb5/public/img/header-grid-mask.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Sitemap: https://rfd.shared.oxide.computer/sitemap.xml 4 | -------------------------------------------------------------------------------- /public/svgs/caution.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 1.5L15 15.5H1L8 1.5ZM7.125 6.16667H8.875V10.8333H7.125V6.16667ZM7.125 13.7502V12.0002H8.875V13.7502H7.125Z" fill="#F5B944"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/svgs/footnote.svg: -------------------------------------------------------------------------------- 1 | <svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 0H4V1L7 1V5H3V3L0 5.5L3 8V6H8V5V1V0Z" fill="#2E8160"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/svgs/header-grid.svg: -------------------------------------------------------------------------------- 1 | <svg width="1266" height="299" viewBox="0 0 1266 299" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <g clip-path="url(#clip0_725_22051)"> 3 | <rect y="-1" width="1" height="566" fill="#080F11"/> 4 | <rect x="33.2896" y="-1" width="1" height="566" fill="#141B1D"/> 5 | <rect x="66.5791" y="-1" width="1" height="566" fill="#141B1D"/> 6 | <rect x="99.8687" y="-1" width="1" height="566" fill="#141B1D"/> 7 | <rect x="133.158" y="-1" width="1" height="566" fill="#141B1D"/> 8 | <rect x="166.447" y="-1" width="1" height="566" fill="#141B1D"/> 9 | <rect x="199.737" y="-1" width="1" height="566" fill="#141B1D"/> 10 | <rect x="233.026" y="-1" width="1" height="566" fill="#141B1D"/> 11 | <rect x="266.316" y="-1" width="1" height="566" fill="#141B1D"/> 12 | <rect x="299.605" y="-1" width="1" height="566" fill="#141B1D"/> 13 | <rect x="332.895" y="-1" width="1" height="566" fill="#141B1D"/> 14 | <rect x="366.184" y="-1" width="1" height="566" fill="#141B1D"/> 15 | <rect x="399.474" y="-1" width="1" height="566" fill="#141B1D"/> 16 | <rect x="432.763" y="-1" width="1" height="566" fill="#141B1D"/> 17 | <rect x="466.053" y="-1" width="1" height="566" fill="#141B1D"/> 18 | <rect x="499.342" y="-1" width="1" height="566" fill="#141B1D"/> 19 | <rect x="532.632" y="-1" width="1" height="566" fill="#141B1D"/> 20 | <rect x="565.921" y="-1" width="1" height="566" fill="#141B1D"/> 21 | <rect x="599.211" y="-1" width="1" height="566" fill="#141B1D"/> 22 | <rect x="632.5" y="-1" width="1" height="566" fill="#141B1D"/> 23 | <rect x="665.79" y="-1" width="1" height="566" fill="#141B1D"/> 24 | <rect x="699.079" y="-1" width="1" height="566" fill="#141B1D"/> 25 | <rect x="732.369" y="-1" width="1" height="566" fill="#141B1D"/> 26 | <rect x="765.658" y="-1" width="1" height="566" fill="#141B1D"/> 27 | <rect x="798.948" y="-1" width="1" height="566" fill="#141B1D"/> 28 | <rect x="832.237" y="-1" width="1" height="566" fill="#141B1D"/> 29 | <rect x="865.526" y="-1" width="1" height="566" fill="#141B1D"/> 30 | <rect x="898.816" y="-1" width="1" height="566" fill="#141B1D"/> 31 | <rect x="932.105" y="-1" width="1" height="566" fill="#141B1D"/> 32 | <rect x="965.395" y="-1" width="1" height="566" fill="#141B1D"/> 33 | <rect x="998.685" y="-1" width="1" height="566" fill="#141B1D"/> 34 | <rect x="1031.97" y="-1" width="1" height="566" fill="#141B1D"/> 35 | <rect x="1065.26" y="-1" width="1" height="566" fill="#141B1D"/> 36 | <rect x="1098.55" y="-1" width="1" height="566" fill="#141B1D"/> 37 | <rect x="1131.84" y="-1" width="1" height="566" fill="#141B1D"/> 38 | <rect x="1165.13" y="-1" width="1" height="566" fill="#141B1D"/> 39 | <rect x="1198.42" y="-1" width="1" height="566" fill="#141B1D"/> 40 | <rect x="1231.71" y="-1" width="1" height="566" fill="#141B1D"/> 41 | <rect x="1265" y="-1" width="1" height="566" fill="#080F11"/> 42 | <rect y="299.605" width="1" height="1266" transform="rotate(-90 0 299.605)" fill="#080F11"/> 43 | <rect y="266.315" width="1" height="1266" transform="rotate(-90 0 266.315)" fill="#141B1D"/> 44 | <rect y="233.026" width="1" height="1266" transform="rotate(-90 0 233.026)" fill="#141B1D"/> 45 | <rect y="199.736" width="1" height="1266" transform="rotate(-90 0 199.736)" fill="#141B1D"/> 46 | <rect y="166.447" width="1" height="1266" transform="rotate(-90 0 166.447)" fill="#141B1D"/> 47 | <rect y="133.158" width="1" height="1266" transform="rotate(-90 0 133.158)" fill="#141B1D"/> 48 | <rect y="99.8682" width="1" height="1266" transform="rotate(-90 0 99.8682)" fill="#141B1D"/> 49 | <rect y="66.5791" width="1" height="1266" transform="rotate(-90 0 66.5791)" fill="#141B1D"/> 50 | <rect y="33.2896" width="1" height="1266" transform="rotate(-90 0 33.2896)" fill="#141B1D"/> 51 | </g> 52 | <defs> 53 | <clipPath id="clip0_725_22051"> 54 | <rect width="1266" height="299" fill="white"/> 55 | </clipPath> 56 | </defs> 57 | </svg> 58 | -------------------------------------------------------------------------------- /public/svgs/info-yellow.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM7 6V4H9V6H7ZM7 12V7H9V12H7Z" fill="#F5B944"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/svgs/info.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM7 6V4H9V6H7ZM7 12V7H9V12H7Z" fill="#48D597"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/svgs/link.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path d="M6.58578 12.2426L8.17677 10.6516C8.46967 10.3588 8.94454 10.3588 9.23743 10.6516L9.59099 11.0052C9.88388 11.2981 9.88388 11.773 9.59099 12.0659L8 13.6569C6.4379 15.2189 3.90524 15.2189 2.34314 13.6569C0.781046 12.0948 0.781046 9.56209 2.34314 8L3.93413 6.40901C4.22703 6.11611 4.7019 6.11611 4.99479 6.40901L5.34835 6.76256C5.64124 7.05545 5.64124 7.53033 5.34835 7.82322L3.75736 9.41421C2.97631 10.1953 2.97631 11.4616 3.75736 12.2426C4.5384 13.0237 5.80473 13.0237 6.58578 12.2426Z" fill="#238A5E"/> 3 | <path d="M12.0659 9.59099C11.773 9.88388 11.2981 9.88388 11.0052 9.59099L10.6516 9.23743C10.3588 8.94454 10.3588 8.46967 10.6516 8.17677L12.2426 6.58578C13.0237 5.80473 13.0237 4.5384 12.2426 3.75736C11.4616 2.97631 10.1953 2.97631 9.41421 3.75736L7.82322 5.34835C7.53033 5.64124 7.05545 5.64124 6.76256 5.34835L6.40901 4.99479C6.11611 4.7019 6.11611 4.22703 6.40901 3.93413L8 2.34314C9.56209 0.781046 12.0948 0.781045 13.6569 2.34314C15.2189 3.90524 15.2189 6.4379 13.6569 8L12.0659 9.59099Z" fill="#238A5E"/> 4 | <path d="M9.94454 5.7019C9.65165 5.40901 9.17677 5.40901 8.88388 5.7019L5.7019 8.88388C5.40901 9.17677 5.40901 9.65165 5.7019 9.94454L6.05545 10.2981C6.34835 10.591 6.82322 10.591 7.11611 10.2981L10.2981 7.11611C10.591 6.82322 10.591 6.34835 10.2981 6.05545L9.94454 5.7019Z" fill="#238A5E"/> 5 | </svg> 6 | -------------------------------------------------------------------------------- /public/svgs/tip.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM7 6V4H9V6H7ZM7 12V7H9V12H7Z" fill="#BE95EB"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /public/svgs/warning.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 | <path fill-rule="evenodd" clip-rule="evenodd" d="M8 1.5L15 15.5H1L8 1.5ZM7.125 6.16667H8.875V10.8333H7.125V6.16667ZM7.125 13.7502V12.0002H8.875V13.7502H7.125Z" fill="#E86886"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /svgr.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | module.exports = { 10 | plugins: ['@svgr/plugin-svgo', '@svgr/plugin-jsx', '@svgr/plugin-prettier'], 11 | typescript: true, 12 | } 13 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | import { 9 | borderRadiusTokens, 10 | colorUtilities, 11 | elevationUtilities, 12 | textUtilities, 13 | } from '@oxide/design-system/styles/dist/tailwind-tokens.ts' 14 | import { type Config } from 'tailwindcss' 15 | import plugin from 'tailwindcss/plugin' 16 | 17 | module.exports = { 18 | corePlugins: { 19 | fontFamily: false, 20 | fontSize: true, 21 | }, 22 | content: [ 23 | './libs/**/*.{ts,tsx,mdx}', 24 | './app/**/*.{ts,tsx}', 25 | 'node_modules/@oxide/design-system/components/**/*.{ts,tsx,jsx,js}', 26 | ], 27 | safelist: ['bg-scrim'], 28 | theme: { 29 | extend: { 30 | screens: { 31 | 400: '400px', 32 | 500: '500px', 33 | '-600': { max: '600px' }, 34 | 600: '600px', 35 | 800: '800px', 36 | 900: '900px', 37 | 1000: '1000px', 38 | 1100: '1100px', 39 | 1200: '1200px', 40 | 1400: '1400px', 41 | 1600: '1600px', 42 | print: { raw: 'print' }, 43 | }, 44 | maxWidth: { 45 | 500: '500px', 46 | 600: '600px', 47 | 620: '620px', 48 | 720: '720px', 49 | 800: '800px', 50 | 900: '900px', 51 | 1000: '1000px', 52 | 1060: '1060px', 53 | 1200: '1200px', 54 | 1400: '1400px', 55 | 1600: '1600px', 56 | 1800: '1800px', 57 | }, 58 | }, 59 | borderRadius: { 60 | ...borderRadiusTokens, 61 | }, 62 | colors: { 63 | transparent: 'transparent', 64 | current: 'currentColor', 65 | }, 66 | backgroundImage: { 67 | 'header-grid-mask': 'radial-gradient(rgba(8,15,17,0) 0%, rgba(8,15,17,1) 100%)', 68 | }, 69 | }, 70 | plugins: [ 71 | plugin(({ addUtilities, addVariant }) => { 72 | addUtilities(textUtilities) 73 | addUtilities(colorUtilities) 74 | addUtilities(elevationUtilities) 75 | addVariant('children', '& > *') 76 | }), 77 | ], 78 | variants: { 79 | extend: { 80 | translate: ['group-hover'], 81 | }, 82 | }, 83 | } satisfies Config 84 | -------------------------------------------------------------------------------- /test/e2e/everything.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { expect, test } from '@playwright/test' 10 | 11 | test('Click around', async ({ page }) => { 12 | await page.goto('/') 13 | await expect(page.getByRole('heading', { name: 'Requests for Discussion' })).toBeVisible() 14 | // we're in public mode so we should see the banner 15 | await expect(page.getByText('Viewing public RFDs')).toBeVisible() 16 | 17 | // can click an RFD 18 | await page.getByRole('link', { name: 'RFD 223' }).click() 19 | await expect( 20 | page.getByRole('heading', { name: 'Web Console Architecture' }), 21 | ).toBeVisible() 22 | await expect(page.getByText('AuthorsDavid Crespo, Justin Bennett')).toBeVisible() 23 | // banner shows up on this page too 24 | await expect(page.getByText('Viewing public RFDs')).toBeVisible() 25 | 26 | await page.getByRole('link', { name: 'Back to index' }).click() 27 | await expect(page.getByRole('heading', { name: 'Requests for Discussion' })).toBeVisible() 28 | }) 29 | 30 | test('Filter by title', async ({ page }) => { 31 | await page.goto('/') 32 | 33 | const rfdLinks = page.getByRole('link', { name: /^RFD/ }) 34 | 35 | // don't know how many public RFDs there are but there are a bunch 36 | expect(await rfdLinks.count()).toBeGreaterThan(10) 37 | 38 | await page.getByPlaceholder('Filter by').fill('standard units') 39 | 40 | // but after you filter there are fewer 41 | expect(await rfdLinks.count()).toEqual(1) 42 | }) 43 | 44 | test('Filter by author', async ({ page }) => { 45 | await page.goto('/') 46 | 47 | const rfdLinks = page.getByRole('link', { name: /^RFD/ }) 48 | 49 | // don't know how many public RFDs there are but there are a bunch 50 | expect(await rfdLinks.count()).toBeGreaterThan(10) 51 | 52 | await page.getByPlaceholder('Filter by').fill('david.crespo') 53 | 54 | // but after you filter there are fewer 55 | expect(await rfdLinks.count()).toEqual(2) 56 | }) 57 | 58 | test('Header filter box', async ({ page }) => { 59 | await page.goto('/rfd/0002') 60 | 61 | await expect( 62 | page.getByRole('heading', { name: 'Mission, Principles and Values' }), 63 | ).toBeVisible() 64 | 65 | await expect(page.getByRole('banner').getByPlaceholder('Search')).toBeHidden() 66 | await page.getByRole('button', { name: 'Select a RFD' }).click() 67 | await page.getByRole('banner').getByPlaceholder('Search').fill('User Networking API') 68 | await page.getByRole('banner').getByPlaceholder('Search').press('Enter') 69 | 70 | await expect(page.getByRole('heading', { name: 'User Networking API' })).toBeVisible() 71 | }) 72 | 73 | test('Direct link to public RFD', async ({ page }) => { 74 | await page.goto('/rfd/0068') 75 | 76 | await expect( 77 | page.getByRole('heading', { name: 'Partnership as Shared Values' }), 78 | ).toBeVisible() 79 | await expect(page.getByText('AuthorsBryan Cantrill')).toBeVisible() 80 | }) 81 | 82 | test('Sign in button', async ({ page }) => { 83 | await page.goto('/') 84 | 85 | await expect(page.getByRole('heading', { name: 'Sign in' })).toBeHidden() 86 | await page.getByRole('link', { name: 'Sign in' }).click() 87 | await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible() 88 | }) 89 | 90 | test('Login redirect on nonexistent or private RFD', async ({ page }) => { 91 | await page.goto('/rfd/4268') 92 | await expect(page).toHaveURL(/\/login\?returnTo=\/rfd\/4268$/) 93 | await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible() 94 | }) 95 | -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import tsconfigPaths from 'vite-tsconfig-paths' 10 | import { defaultExclude, defineConfig } from 'vitest/config' 11 | 12 | export default defineConfig({ 13 | plugins: [tsconfigPaths()], 14 | test: { 15 | exclude: ['test/e2e/**', ...defaultExclude], 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["vite.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "incremental": true, 5 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "allowJs": false, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"] 18 | }, 19 | 20 | // Remix takes care of building everything in `remix build`. 21 | "noEmit": true, 22 | "types": ["@remix-run/node", "vite/client"], 23 | "skipLibCheck": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /types/asciidoctor-mathjax.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | declare module '@djencks/asciidoctor-mathjax' { 10 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 11 | type Registry = import('@asciidoctor/core').Extensions.Registry 12 | type Config = {} 13 | 14 | function register(registry: Registry, config?: Config): Registry 15 | } 16 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/js/viewscript.js", 5 | "destination": "https://trck.oxide.computer/js/plausible.js" 6 | }, 7 | { "source": "/api/event", "destination": "https://trck.oxide.computer/api/event" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import { vitePlugin as remix } from '@remix-run/dev' 10 | import { vercelPreset } from '@vercel/remix/vite' 11 | import { defineConfig } from 'vite' 12 | import tsconfigPaths from 'vite-tsconfig-paths' 13 | 14 | import { LocalRfdPlugin } from './vite/local-rfd-plugin' 15 | 16 | declare module '@remix-run/server-runtime' { 17 | interface Future { 18 | v3_singleFetch: true 19 | } 20 | } 21 | 22 | const plugins = [ 23 | remix({ presets: [vercelPreset()], future: { v3_singleFetch: true } }), 24 | tsconfigPaths(), 25 | ] 26 | 27 | const localRepo = process.env.LOCAL_RFD_REPO 28 | if (localRepo) plugins.push(LocalRfdPlugin(localRepo)) 29 | 30 | export default defineConfig({ 31 | plugins, 32 | server: { 33 | port: 3000, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /vite/local-rfd-plugin.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This Source Code Form is subject to the terms of the Mozilla Public 3 | * License, v. 2.0. If a copy of the MPL was not distributed with this 4 | * file, you can obtain one at https://mozilla.org/MPL/2.0/. 5 | * 6 | * Copyright Oxide Computer Company 7 | */ 8 | 9 | import fs from 'node:fs' 10 | import path from 'node:path' 11 | import { type Plugin } from 'vite' 12 | 13 | export function LocalRfdPlugin(localRepo: string): Plugin { 14 | return { 15 | name: 'vite-plugin-local-rfd', 16 | buildStart() { 17 | // blow up if it doesn't exist 18 | fs.stat(localRepo, (err) => { 19 | if (err) { 20 | console.error(`Error: LOCAL_RFD_REPO ${localRepo} does not exist`) 21 | process.exit(1) 22 | } 23 | }) 24 | this.addWatchFile(path.join(localRepo, 'rfd')) 25 | }, 26 | handleHotUpdate(ctx) { 27 | if (ctx.file.startsWith(localRepo)) { 28 | ctx.server.config.logger.info(`reloading: ${ctx.file} changed`) 29 | // bit of a hack but I'm not sure what else to do 30 | ctx.server.ws.send({ type: 'full-reload', path: 'app/services/rfd.server.ts' }) 31 | } 32 | }, 33 | } 34 | } 35 | --------------------------------------------------------------------------------