From b25f1219910b8c1ed86eb4927d96c1f6a4e1ca27 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Thu, 29 Jan 2026 13:21:19 +0000 Subject: [PATCH 01/15] First pass at RFD site config file --- .dockerignore | 26 +++++++--- Dockerfile | 23 +++++++++ app/components/Header.tsx | 25 +++++---- app/components/NewRfdButton.tsx | 4 +- app/components/PublicBanner.tsx | 55 ++++++++------------ app/components/Search.tsx | 16 ++++-- app/components/rfd/MoreDropdown.tsx | 14 ++++-- app/root.tsx | 44 ++++++++++++---- app/services/config.server.ts | 37 ++++++++++++++ app/services/github-discussion.server.ts | 28 ++++++++--- app/styles/index.css | 1 + app/styles/overrides.css | 15 ++++++ app/types/site-config.ts | 48 ++++++++++++++++++ package.json | 1 + site.config.ts | 64 ++++++++++++++++++++++++ 15 files changed, 326 insertions(+), 75 deletions(-) create mode 100644 Dockerfile create mode 100644 app/services/config.server.ts create mode 100644 app/styles/overrides.css create mode 100644 app/types/site-config.ts create mode 100644 site.config.ts diff --git a/.dockerignore b/.dockerignore index e4c07a3..10f3922 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,22 @@ -fly.toml -/node_modules +# Dependencies (reinstalled in Docker) +node_modules + +# Build artifacts (regenerated in Docker) +.cache +/build +/.react-router + +# Environment files (provided at runtime) +.env + +# Version control (not needed in image) +.git +.github + +# Testing (not needed in production) +test +test-results + +# System files *.log .DS_Store -.env -/.cache -/public/build -/build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d39fe32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM node:22-alpine AS development-dependencies-env +COPY . /app +WORKDIR /app +RUN npm ci + +FROM node:22-alpine AS production-dependencies-env +COPY ./package.json package-lock.json /app/ +WORKDIR /app +RUN npm ci --omit=dev + +FROM node:22-alpine AS build-env +COPY . /app/ +COPY --from=development-dependencies-env /app/node_modules /app/node_modules +WORKDIR /app +RUN npm run build + +FROM node:22-alpine +COPY ./package.json package-lock.json /app/ +COPY --from=production-dependencies-env /app/node_modules /app/node_modules +COPY --from=build-env /app/build /app/build +COPY --from=build-env /app/public /app/public +WORKDIR /app +CMD ["npm", "run", "start"] diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 2864250..c5a4e00 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -27,7 +27,7 @@ export type SmallRfdItems = { } export default function Header({ currentRfd }: { currentRfd?: RfdItem }) { - const { user, rfds, localMode, inlineComments } = useRootLoaderData() + const { user, rfds, localMode, inlineComments, features } = useRootLoaderData() const fetcher = useFetcher() @@ -49,9 +49,10 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) { // memoized to avoid render churn in useKey const toggleSearchMenu = useCallback(() => { + if (!features.search) return false setOpen(!open) return false // Returning false prevents default behaviour in Firefox - }, [open]) + }, [open, features.search]) useKey('mod+k', toggleSearchMenu, { global: true }) @@ -72,14 +73,18 @@ export default function Header({ currentRfd }: { currentRfd?: RfdItem }) {
- - setOpen(false)} /> + {features.search && ( + <> + + setOpen(false)} /> + + )} {user ? ( diff --git a/app/components/NewRfdButton.tsx b/app/components/NewRfdButton.tsx index ab0a3e6..c04b97c 100644 --- a/app/components/NewRfdButton.tsx +++ b/app/components/NewRfdButton.tsx @@ -15,7 +15,7 @@ import Modal from './Modal' const NewRfdButton = () => { const dialog = useDialogStore() - const newRfdNumber = useRootLoaderData().newRfdNumber + const { newRfdNumber, config } = useRootLoaderData() return ( <> @@ -31,7 +31,7 @@ const NewRfdButton = () => {

There is a prototype script in the rfd{' '} repository diff --git a/app/components/PublicBanner.tsx b/app/components/PublicBanner.tsx index 9434b74..6123a23 100644 --- a/app/components/PublicBanner.tsx +++ b/app/components/PublicBanner.tsx @@ -7,35 +7,25 @@ */ import { useDialogStore } from '@ariakit/react' -import { type ReactNode } from 'react' import { Link } from 'react-router' import Icon from '~/components/Icon' +import { useRootLoaderData } from '~/root' import Modal from './Modal' -function ExternalLink({ href, children }: { href: string; children: ReactNode }) { - return ( - - {children} - - ) -} - export function PublicBanner() { + const { config } = useRootLoaderData() const dialog = useDialogStore() + if (!config.publicBanner || !config.publicBanner.enabled) return null + return ( <> {/* The [&+*]:pt-10 style is to ensure the page container isn't pushed out of screen as it uses 100vh for layout */}

- Viewing public RFDs. + {config.publicBanner.text || 'Viewing public RFDs'}
- +

These are the publicly available{' '} @@ -55,29 +45,26 @@ export function PublicBanner() { > RFDs {' '} - from Oxide. Those - with access should{' '} + from{' '} + + {config.organization.name} + + . Those with access should{' '} sign in {' '} to view the full directory of RFDs.

-

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

-

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

+ {config.publicBanner.learnMoreContent && ( +
+ )}
diff --git a/app/components/Search.tsx b/app/components/Search.tsx index 19716e8..565effb 100644 --- a/app/components/Search.tsx +++ b/app/components/Search.tsx @@ -30,15 +30,19 @@ import { Link, useNavigate } from 'react-router' import Icon from '~/components/Icon' import StatusBadge from '~/components/StatusBadge' import { useSteppedScroll } from '~/hooks/use-stepped-scroll' +import { useRootLoaderData } from '~/root' import type { RfdItem } from '~/services/rfd.server' const Search = ({ open, onClose }: { open: boolean; onClose: () => void }) => { + const { config } = useRootLoaderData() const searchClient = useRef(null) useEffect(() => { - const { searchClient: client } = instantMeiliSearch( - 'https://search.rfd.shared.oxide.computer', - ) + if (!config.features.search || !config.search) { + return + } + + const { searchClient: client } = instantMeiliSearch(config.search.url) // Overriding search function to implement our custom search backend. We provide a search route // that proxies out to the RFD API search endpoint. This route accepts slightly different @@ -91,7 +95,11 @@ const Search = ({ open, onClose }: { open: boolean; onClose: () => void }) => { }) }, } - }, []) + }, [config.features.search, config.search]) + + if (!config.features.search || !config.search) { + return null + } if (searchClient.current) { return ( diff --git a/app/components/rfd/MoreDropdown.tsx b/app/components/rfd/MoreDropdown.tsx index 962ce86..a6537a9 100644 --- a/app/components/rfd/MoreDropdown.tsx +++ b/app/components/rfd/MoreDropdown.tsx @@ -10,6 +10,7 @@ import * as Dropdown from '@radix-ui/react-dropdown-menu' import { useState } from 'react' import { useLoaderData } from 'react-router' +import { useRootLoaderData } from '~/root' import type { loader } from '~/routes/rfd.$slug' import { DropdownItem, DropdownLink, DropdownMenu } from '../Dropdown' @@ -18,6 +19,7 @@ import RfdJobsMonitor from './RfdJobsMonitor' const MoreDropdown = () => { const { rfd } = useLoaderData() + const { features } = useRootLoaderData() const [dialogOpen, setDialogOpen] = useState(false) const jobsDialogStore = useDialogStore({ open: dialogOpen, setOpen: setDialogOpen }) @@ -31,9 +33,11 @@ const MoreDropdown = () => { Processing jobs - - GitHub discussion - + {features.discussions && ( + + GitHub discussion + + )} GitHub source @@ -47,7 +51,9 @@ const MoreDropdown = () => { )} - View PDF + {features.pdf && ( + View PDF + )} diff --git a/app/root.tsx b/app/root.tsx index 401119d..5d66dbe 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -27,6 +27,7 @@ import styles from '~/styles/index.css?url' import LoadingBar from './components/LoadingBar' import { authenticate, logout } from './services/auth.server' +import { getSiteConfig } from './services/config.server' import { inlineCommentsCookie, themeCookie } from './services/cookies.server' import { isLocalMode } from './services/rfd.local.server' import { @@ -36,13 +37,27 @@ import { provideNewRfdNumber, } from './services/rfd.server' -export const meta: MetaFunction = () => { - return [{ title: 'RFD / Oxide' }] +export const meta: MetaFunction = ({ data }) => { + if (!data?.config) { + return [{ title: 'RFD' }] + } + + const orgName = data.config.organization.name + const description = data.config.site.description + const metaTags: ReturnType = [{ title: `RFD / ${orgName}` }] + if (description) { + metaTags.push({ name: 'description', content: description } as { + name: string + content: string + }) + } + return metaTags } export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }] export const loader = async ({ request }: LoaderFunctionArgs) => { + const config = await getSiteConfig() const theme = (await themeCookie.parse(request.headers.get('Cookie'))) ?? 'dark-mode' const inlineComments = (await inlineCommentsCookie.parse(request.headers.get('Cookie'))) ?? true @@ -57,6 +72,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { // Any data added to the ENV key of this loader will be injected into the // global window object (window.ENV) + config, + features: config.features, // Make features available separately for client-side checks theme, inlineComments, user, @@ -74,6 +91,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { // Convince remix that a return type will always be provided return { + config, + features: config.features, theme, inlineComments, user, @@ -110,7 +129,15 @@ export function ErrorBoundary() { } const queryClient = new QueryClient() -const Layout = ({ children, theme }: { children: React.ReactNode; theme?: string }) => ( +const Layout = ({ + children, + theme, + headContent, +}: { + children: React.ReactNode + theme?: string + headContent?: string +}) => ( @@ -120,11 +147,10 @@ const Layout = ({ children, theme }: { children: React.ReactNode; theme?: string - {/* Use plausible analytics only on Vercel */} - {process.env.NODE_ENV === 'production' && ( -