diff --git a/.docker/caddy/Caddyfile b/.docker/caddy/Caddyfile index c068e857c..cf2c5365d 100644 --- a/.docker/caddy/Caddyfile +++ b/.docker/caddy/Caddyfile @@ -35,7 +35,7 @@ handle @api { uri strip_prefix /_roomote-api - reverse_proxy host.docker.internal:13101 { + reverse_proxy host.docker.internal:13001 { lb_try_duration 10s lb_try_interval 250ms } @@ -58,7 +58,7 @@ } handle { - reverse_proxy host.docker.internal:13100 { + reverse_proxy host.docker.internal:13000 { lb_try_duration 10s lb_try_interval 250ms } diff --git a/apps/web/.gitignore b/apps/web/.gitignore index c3d8993af..8a52c871c 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -2,6 +2,7 @@ /.next /.source /out +/public/docs next-env.d.ts *storybook.log diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 914de3be2..b56c1c2f1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -37,7 +37,7 @@ const nextConfig: NextConfig = { // Always bundle the env files the runtime may need so preview deploys can // load preview secrets even when build-time env detection resolves differently. outputFileTracingIncludes: { - '/*': webEnvFiles, + '/*': [...webEnvFiles, '../docs/**/*'], }, experimental: { serverActions: { @@ -68,9 +68,6 @@ const nextConfig: NextConfig = { // (e.g. "taskid-web.preview-john.ngrok.app"), so we need *.domain entries. allowedDevOrigins: getAllowedDevOrigins(process.env.PREVIEW_DOMAINS), async redirects() { - // Public product docs now live at the standalone Mintlify site - // (https://docs.roomote.dev). Preserve old in-app /docs URLs by - // redirecting them to the external docs site. return [ { source: '/docs', diff --git a/apps/web/package.json b/apps/web/package.json index cb31e6684..769d11f91 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,10 +9,11 @@ "check-types": "NODE_OPTIONS='--max-old-space-size=4096' tsc --noEmit", "check-types:fast": "tsgo --noEmit", "test": "dotenvx run -f ../../.env.test -- vitest", - "dev:prepare": "rimraf .next", - "dev": "dotenvx run -f ../../.env.local -- next dev --turbopack", - "build": "NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build", - "build:debug": "NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build --experimental-debug-memory-usage", + "docs:assets": "node ./scripts/sync-docs-assets.mjs", + "dev:prepare": "rimraf .next && pnpm docs:assets", + "dev": "pnpm docs:assets && dotenvx run -f ../../.env.local -- next dev --turbopack", + "build": "pnpm docs:assets && NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build", + "build:debug": "pnpm docs:assets && NODE_OPTIONS='--max-old-space-size=4096' node ./scripts/run-with-app-env.mjs --default-env production -- next build --experimental-debug-memory-usage", "start": "node ./scripts/run-with-app-env.mjs --default-env production -- next start", "clean": "rimraf .next .turbo out", "trigger-task-suggestions": "tsx scripts/trigger-task-suggestions.ts", @@ -86,10 +87,12 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "gray-matter": "^4.0.3", "lucide-react": "^0.575.0", "modal": "^0.7.6", "motion": "^12.29.2", "next": "^16.2.11", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "pino": "^9.7.0", "pino-pretty": "^13.1.3", @@ -100,6 +103,7 @@ "react-resizable-panels": "^2.1.7", "recharts": "^3.8.0", "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", "remove-markdown": "^0.6.3", "shiki": "^3.19.0", "simple-icons": "16.12.0", diff --git a/apps/web/scripts/sync-docs-assets.mjs b/apps/web/scripts/sync-docs-assets.mjs new file mode 100644 index 000000000..f07f9f74f --- /dev/null +++ b/apps/web/scripts/sync-docs-assets.mjs @@ -0,0 +1,9 @@ +import { cpSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const source = resolve(import.meta.dirname, '../../docs/logo'); +const destination = resolve(import.meta.dirname, '../public/docs/logo'); + +if (existsSync(source)) { + cpSync(source, destination, { recursive: true }); +} diff --git a/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx b/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx new file mode 100644 index 000000000..e62395f08 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/DocsMdx.client.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; + +import { docsMdxComponents } from './DocsMdx'; + +describe('docsMdxComponents', () => { + it('opens documentation links in a new tab', () => { + const DocsLink = docsMdxComponents.a; + + render( + + Self-hosting + , + ); + + const link = screen.getByRole('link', { name: 'Self-hosting' }); + expect(link).toHaveAttribute( + 'href', + 'https://docs.roomote.dev/self-hosting', + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx b/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx new file mode 100644 index 000000000..3d90f3c29 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/DocsMdx.tsx @@ -0,0 +1,95 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { + Alert, + AlertDescription, + AlertTriangle, + Info, +} from '@/components/system'; +import { DOCS_BASE_URL } from '@/lib/docs'; + +function DocsLink({ href = '', ...props }: ComponentProps<'a'>) { + const resolvedHref = href.startsWith('/') ? `${DOCS_BASE_URL}${href}` : href; + + return ( + + ); +} + +function Warning({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ); +} + +function Tip({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ); +} + +function Steps({ children }: { children: ReactNode }) { + return
    {children}
; +} + +function Step({ title, children }: { title: string; children: ReactNode }) { + return ( +
  • +

    {title}

    +
    {children}
    +
  • + ); +} + +function IntegrationName({ + href, + icon, + name, +}: { + href: string; + icon: string; + name: string; +}) { + const manualIcons: Record = { + daytona: '/docs/logo/integrations/daytona.svg', + e2b: '/docs/logo/integrations/e2b.svg', + blaxel: '/docs/logo/integrations/blaxel.svg', + }; + const iconSrc = + manualIcons[icon] ?? + (icon.startsWith('/') + ? `/docs${icon}` + : `https://api.iconify.design/simple-icons:${icon}.svg?color=currentColor`); + + return ( + + {/* This mirrors the Mintlify snippet and external icons do not need Next optimization. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + + {name} + + ); +} + +export const docsMdxComponents = { + a: DocsLink, + Warning, + Tip, + Steps, + Step, + IntegrationName, +}; diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx new file mode 100644 index 000000000..878a85ca7 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams('step=auth-provider'), +})); + +import { SetupDocs } from './SetupDocs'; +import { getSetupDocsPath, getSetupDocsStep } from './setup-docs'; + +describe('SetupDocs', () => { + it('maps setup steps to the matching documentation pages', () => { + expect(getSetupDocsPath('auth-provider')).toBe('communications'); + expect( + getSetupDocsPath('auth-env-vars', { authProvider: 'microsoft' }), + ).toBe('providers/communications/microsoft-teams'); + expect(getSetupDocsPath('slack', { authProvider: 'microsoft' })).toBe( + 'providers/communications/microsoft-teams', + ); + expect( + getSetupDocsPath('source-control-connect', { + sourceControlProvider: 'github', + }), + ).toBe('providers/source-control/github'); + expect(getSetupDocsPath('compute-config', { computeProvider: 'e2b' })).toBe( + 'providers/compute/e2b', + ); + expect(getSetupDocsPath('env-vars', { modelProvider: 'vllm' })).toBe( + 'providers/inference/vllm', + ); + expect(getSetupDocsPath('repo-selection')).toBe('environments'); + expect(getSetupDocsPath('welcome')).toBeNull(); + expect(getSetupDocsStep('email-account')).toBe('email-account'); + expect(getSetupDocsStep(null)).toBe('welcome'); + }); + + it('opens and closes the desktop documentation frame', () => { + function SetupDocsHarness() { + const [isOpen, setIsOpen] = useState(false); + + return ( + +

    Docs content

    +
    + ); + } + + render(); + + expect(screen.queryByText('Docs content')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Need help? Docs are here' }), + ); + + expect(screen.getByText('Docs content')).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: 'Open this documentation page in a new tab', + }), + ).toHaveAttribute('href', 'https://docs.roomote.dev/communications'); + + fireEvent.click(screen.getByRole('button', { name: 'Close docs' })); + + expect(screen.queryByText('Docs content')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx new file mode 100644 index 000000000..9be014a98 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.tsx @@ -0,0 +1,87 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button, ExternalLink } from '@/components/system'; + +import { getSetupDocsPath, getSetupDocsStep } from './setup-docs'; +import { ArrowLeftToLine, ArrowRightToLine } from 'lucide-react'; + +export function SetupDocs({ + isOpen, + onOpenChange, + children, +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + children: ReactNode; +}) { + const searchParams = useSearchParams(); + const docsPath = getSetupDocsPath( + getSetupDocsStep(searchParams?.get('step') ?? null), + { + authProvider: searchParams?.get('authProvider'), + computeProvider: searchParams?.get('computeProvider'), + modelProvider: searchParams?.get('modelProvider'), + sourceControlProvider: searchParams?.get('sourceControlProvider'), + }, + ); + + return ( + <> +
    + + ); +} diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx new file mode 100644 index 000000000..89b43db06 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/SetupDocsContext.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; + +const SetupDocsContext = createContext<(content: ReactNode) => void>(() => {}); + +export function SetupDocsContentProvider({ + children, + setContent, +}: { + children: ReactNode; + setContent: (content: ReactNode) => void; +}) { + return ( + + {children} + + ); +} + +export function useSetSetupDocsContent() { + return useContext(SetupDocsContext); +} diff --git a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx index e9be4d671..d1d42f39b 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; import { useRouter } from 'next/navigation'; import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser'; @@ -11,6 +12,12 @@ import { UserMenu, } from '@/components/layout'; import { Spinner } from '@/components/system'; +import { cn } from '@/lib/utils'; + +import { SetupDocs } from './SetupDocs'; +import { SetupDocsContentProvider } from './SetupDocsContext'; + +const SETUP_DOCS_OPEN_STORAGE_KEY = 'roomote:setup-docs-open'; export function SetupLayoutClient({ children }: { children: React.ReactNode }) { const router = useRouter(); @@ -18,6 +25,29 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) { const setupBootstrapOpen = useSetupBootstrapOpen(); const [userMenuPortalContainer, setUserMenuPortalContainer] = useState(null); + const [isDocsOpen, setIsDocsOpen] = useState(false); + const [docsContent, setDocsContent] = useState(null); + const hasDocsContent = docsContent !== null; + + useEffect(() => { + try { + setIsDocsOpen( + window.localStorage.getItem(SETUP_DOCS_OPEN_STORAGE_KEY) === 'true', + ); + } catch { + // Ignore localStorage failures. + } + }, []); + + const handleDocsOpenChange = (isOpen: boolean) => { + setIsDocsOpen(isOpen); + + try { + window.localStorage.setItem(SETUP_DOCS_OPEN_STORAGE_KEY, String(isOpen)); + } catch { + // Ignore localStorage failures. + } + }; useEffect(() => { if (authStatus === 'signed-out' && !setupBootstrapOpen) { @@ -34,31 +64,51 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) { } return ( -
    - -
    - - {isSignedIn ? ( - <> -
    - - -
    - - ) : null} + +
    + +
    + + {hasDocsContent ? ( + + {docsContent} + + ) : null} + {isSignedIn ? ( + <> +
    + + +
    + + ) : null} -
    -
    -
    - - {children} +
    +
    +
    + + {children} +
    -
    - -
    + +
    + ); } diff --git a/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx new file mode 100644 index 000000000..6f37c62c6 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/SetupPageClient.tsx @@ -0,0 +1,851 @@ +'use client'; + +import { AnimatePresence, motion } from 'motion/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { + getSetupNewComputeProvisioningState, + isComputeCredentialField, + isSetupProvisionableComputeProvider, + type ComputeProvider, + type SetupModelProviderId, + type SourceControlProvider, +} from '@roomote/types'; + +import { useRedirectToSignIn } from '@/hooks/useSignInRedirect'; +import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser'; +import { + INVITE_COOKIE_NAME, + readInviteTokenFromDocumentCookie, +} from '@/lib/invite-cookie'; +import { + DEFAULT_SETUP_REDIRECT_PATH, + getSetupRedirectPath, +} from '@/lib/setup-status'; +import { useTRPC } from '@/trpc/client'; +import { Button, Spinner } from '@/components/system'; + +import { StepWelcome } from './StepWelcome'; +import { StepAuthEnvVars } from './StepAuthEnvVars'; +import { StepBootstrapAccount } from './StepBootstrapAccount'; +import { StepBootstrapEmailPassword } from './StepBootstrapEmailPassword'; +import { StepSetupToken } from './StepSetupToken'; +import { + StepAuthProvider, + type CommunicationProviderChoice, +} from './StepAuthProvider'; +import { StepTelegramSetup } from './StepTelegramSetup'; +import { StepDiscordSetup } from './StepDiscordSetup'; +import { StepInferenceProvider } from './StepInferenceProvider'; +import { StepComputeProvider } from './StepComputeProvider'; +import { StepComputeConfig } from './StepComputeConfig'; +import { StepSourceControlProvider } from './StepSourceControlProvider'; +import { StepSourceControlConfig } from './StepSourceControlConfig'; +import { StepSourceControlConnect } from './StepSourceControlConnect'; +import { StepCommunicationConnect } from './StepCommunicationConnect'; +import { StepInvoke } from './StepInvoke'; +import { useSetupFlow } from './hooks'; +import { StepRepoSelection, type SetupRetryReason } from './StepRepoSelection'; +import { getSetupStepPath } from './types'; +import { useSetSetupDocsContent } from './SetupDocsContext'; +import { + getBootstrapAuthProvider, + getBootstrapStepPath, + getBootstrapStepFromSetupStepParam, + getBootstrapStepAfterWelcome, + getNextBootstrapStep, + type BootstrapStep, +} from './bootstrapFlow'; + +function getSetupRetryReason(status: { + onboardingFailed: boolean; + onboardingTaskStatus: string | null; + onboardingTaskPhase?: string | null; + matchingEnvironment: { id: string; name: string } | null; +}): SetupRetryReason | null { + if (!status.onboardingFailed) { + return null; + } + + if ( + (status.onboardingTaskStatus === 'completed' || + (status.onboardingTaskStatus === 'idle' && + status.onboardingTaskPhase === 'waiting_for_prompt')) && + status.matchingEnvironment === null + ) { + return 'no-environment'; + } + + if (status.onboardingTaskStatus === 'canceled') { + return 'task-canceled'; + } + + return 'task-failed'; +} + +function getInitialBootstrapStep(): BootstrapStep { + if (typeof window === 'undefined') { + return 'welcome'; + } + + return ( + getBootstrapStepFromSetupStepParam( + new URLSearchParams(window.location.search).get('step'), + ) ?? 'welcome' + ); +} + +const BOOTSTRAP_STEPS: readonly BootstrapStep[] = [ + 'welcome', + 'email-account', + 'email-password', + 'auth-provider', + 'auth-env-vars', +]; + +export default function SetupPageClient({ + setupDocsContent, +}: { + setupDocsContent: ReactNode; +}) { + const router = useRouter(); + const setSetupDocsContent = useSetSetupDocsContent(); + const setupBootstrapOpen = useSetupBootstrapOpen(); + const { authStatus, isSignedIn, user } = useUser(); + const isAdmin = user?.isAdmin === true; + + useEffect(() => { + setSetupDocsContent(setupDocsContent); + }, [setSetupDocsContent, setupDocsContent]); + const shouldRedirectToSignIn = + authStatus === 'signed-out' && !setupBootstrapOpen; + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [bootstrapStep, setBootstrapStep] = useState( + getInitialBootstrapStep, + ); + const [bootstrapTransitionDirection, setBootstrapTransitionDirection] = + useState<'forward' | 'backward'>('forward'); + const bootstrapStepRef = useRef(bootstrapStep); + bootstrapStepRef.current = bootstrapStep; + const setBootstrapStepWithTransition = useCallback( + (nextStep: BootstrapStep) => { + const currentIndex = BOOTSTRAP_STEPS.indexOf(bootstrapStepRef.current); + const nextIndex = BOOTSTRAP_STEPS.indexOf(nextStep); + + if (nextStep !== bootstrapStepRef.current) { + setBootstrapTransitionDirection( + nextIndex >= currentIndex ? 'forward' : 'backward', + ); + } + + bootstrapStepRef.current = nextStep; + setBootstrapStep(nextStep); + router.replace( + getBootstrapStepPath( + nextStep, + new URLSearchParams(window.location.search), + ), + ); + }, + [router], + ); + const [pendingAuthProvider, setPendingAuthProvider] = + useState(null); + const pendingSetupAuthProvider = + pendingAuthProvider === 'telegram' || pendingAuthProvider === 'discord' + ? null + : pendingAuthProvider; + const [pendingSourceControlProvider, setPendingSourceControlProvider] = + useState(null); + const [pendingComputeProvider, setPendingComputeProvider] = + useState(null); + const [pendingModelProvider, setPendingModelProvider] = + useState(null); + const [setupToken, setSetupToken] = useState(() => { + if (typeof window === 'undefined') { + return null; + } + + // OAuth sign-in round-trips return to /setup without the ?token= query + // param, so fall back to the invite cookie to avoid bouncing the visitor + // back to the setup-token gate mid-flow. + return ( + new URLSearchParams(window.location.search).get('token') ?? + readInviteTokenFromDocumentCookie() + ); + }); + const { data: bootstrapStatus, isLoading: isBootstrapLoading } = useQuery( + trpc.setupBootstrap.status.queryOptions( + setupToken ? { setupToken } : undefined, + { + enabled: !isSignedIn && setupBootstrapOpen, + staleTime: 5_000, + }, + ), + ); + const { + data: setupStatus, + isLoading: isSetupStatusLoading, + isError: isSetupStatusError, + } = useQuery( + trpc.setup.status.queryOptions(undefined, { + enabled: isSignedIn && isAdmin, + staleTime: 30_000, + }), + ); + const { + step, + transitionDirection, + entryContext, + goToStep, + goToPreviousStep, + goToNextStep, + canGoBack, + goToNextPostOnboardingStep, + status, + setupSession, + isLoading, + isError, + } = useSetupFlow({ + enabled: isSignedIn && isAdmin, + pendingAuthProvider: pendingSetupAuthProvider, + }); + const saveSourceControlProviderChoice = useMutation( + trpc.setupNew.saveSourceControlProviderChoice.mutationOptions({ + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + setPendingSourceControlProvider(variables.provider); + goToStep('source-control-config'); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + const saveAuthProviderChoice = useMutation( + trpc.setupNew.saveAuthProviderChoice.mutationOptions({ + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + // Only expose the choice as pending once the save has succeeded, so a + // failed request can never prematurely skip the chooser with an + // unsaved selection. + setPendingAuthProvider(variables.provider); + goToStep('auth-env-vars'); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + const saveComputeProviderChoice = useMutation( + trpc.setupNew.saveComputeProviderChoice.mutationOptions({ + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ + queryKey: trpc.setupNew.status.queryKey(), + }); + setPendingComputeProvider(variables.provider); + const selectedProvider = status?.computeSetup.providers.find( + (provider) => provider.provider === variables.provider, + ); + + // Credentialless providers stay on the short path even when they + // expose optional advanced settings later in Settings. + if ( + selectedProvider && + !selectedProvider.fields.some(isComputeCredentialField) + ) { + goToNextStep(); + return; + } + + // Pin the config step so already-satisfied credentials stay + // reviewable/editable instead of auto-skipping past them. + goToStep('compute-config', { revisit: true }); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + const hasPersistedSelectedSuggestedTasks = ( + status?.queuedOnboardingTasks ?? [] + ).some((task) => task.suggestionId !== null); + const bootstrapAuthProvider = getBootstrapAuthProvider( + bootstrapStatus?.authSetup, + pendingSetupAuthProvider, + ); + const setupRetryReason = status ? getSetupRetryReason(status) : null; + const selectedComputeProvider = + pendingComputeProvider ?? status?.setupNewState.computeProvider; + const selectedAuthProvider = + pendingAuthProvider ?? + status?.setupNewState.authProvider ?? + status?.authSetup.runtimeConfiguredProvider ?? + status?.authSetup.selectedProvider; + const selectedSourceControlProvider = + pendingSourceControlProvider ?? + status?.setupNewState.sourceControlProvider ?? + status?.sourceControlSetup.runtimeConfiguredProvider ?? + status?.sourceControlSetup.selectedProvider; + const selectedModelProvider = + pendingModelProvider ?? status?.setupNewState.modelProvider; + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const providerParams = { + authProvider: selectedAuthProvider, + computeProvider: selectedComputeProvider, + modelProvider: selectedModelProvider, + sourceControlProvider: selectedSourceControlProvider, + }; + let changed = false; + + for (const [key, value] of Object.entries(providerParams)) { + if (value && params.get(key) !== value) { + params.set(key, value); + changed = true; + } else if (!value && params.has(key)) { + params.delete(key); + changed = true; + } + } + + if (changed) { + router.replace(`${window.location.pathname}?${params}`); + } + }, [ + router, + selectedAuthProvider, + selectedComputeProvider, + selectedModelProvider, + selectedSourceControlProvider, + ]); + const computeProvisioning = + status && + selectedComputeProvider && + isSetupProvisionableComputeProvider(selectedComputeProvider) + ? getSetupNewComputeProvisioningState( + status.setupNewState, + selectedComputeProvider, + ) + : null; + const shouldEvaluateSetupRedirect = isSignedIn && isAdmin; + const setupRedirectPath = + shouldEvaluateSetupRedirect && !isSetupStatusError && setupStatus != null + ? getSetupRedirectPath(setupStatus) + : null; + // When the user finishes setup from the invoke step, setup.status flips to + // completed before StepInvoke navigates to the onboarding task. Without this + // guard the effect below would replace('/') first and flash Home. + const observedIncompleteSetupRef = useRef(false); + useRedirectToSignIn(shouldRedirectToSignIn); + + useEffect(() => { + if (setupStatus != null && setupStatus.setupCompletedAt == null) { + observedIncompleteSetupRef.current = true; + } + }, [setupStatus]); + + useEffect(() => { + if (authStatus === 'signed-in' && !isAdmin) { + router.replace('/'); + return; + } + + if (!shouldEvaluateSetupRedirect) { + return; + } + + if (!isSetupStatusLoading && !isSetupStatusError && setupStatus != null) { + if ( + setupRedirectPath && + setupRedirectPath !== DEFAULT_SETUP_REDIRECT_PATH + ) { + router.replace(setupRedirectPath); + return; + } + + if (setupRedirectPath === null && !observedIncompleteSetupRef.current) { + router.replace('/'); + return; + } + } + + if (isError) { + router.replace('/'); + } + }, [ + authStatus, + isAdmin, + isError, + isSetupStatusError, + isSetupStatusLoading, + shouldEvaluateSetupRedirect, + router, + setupStatus, + setupRedirectPath, + ]); + + useEffect(() => { + if (isSignedIn || !bootstrapStatus) { + return; + } + + if (!bootstrapStatus.setupOpen) { + router.replace('/sign-in'); + return; + } + + const currentStep = bootstrapStepRef.current; + let nextStep = currentStep; + + if (currentStep !== 'welcome') { + if (currentStep === 'email-account' || currentStep === 'email-password') { + const nextBootstrapStep = getBootstrapStepAfterWelcome( + bootstrapStatus.authSetup, + ); + nextStep = + nextBootstrapStep === 'email-account' + ? currentStep + : nextBootstrapStep; + } else { + nextStep = getNextBootstrapStep( + bootstrapStatus.authSetup, + pendingSetupAuthProvider, + ); + } + } + + if (nextStep !== currentStep) { + setBootstrapStepWithTransition(nextStep); + } + }, [ + bootstrapStatus, + isSignedIn, + pendingSetupAuthProvider, + router, + setBootstrapStepWithTransition, + ]); + + useEffect(() => { + if (status?.setupNewState.authProvider) { + setPendingAuthProvider(null); + } + }, [status?.setupNewState.authProvider]); + + useEffect(() => { + if (status?.setupNewState.modelProvider) { + setPendingModelProvider(null); + } + }, [status?.setupNewState.modelProvider]); + + useEffect(() => { + // The setup token doubles as the system invite for the first admin + // account, so it has to reach the sign-up request as the invite cookie. + // Only persist it once the server confirms it, so a stale or mistyped + // ?token= can never clobber a still-valid cookie. + if (!isSignedIn && setupToken && bootstrapStatus?.setupTokenSatisfied) { + document.cookie = `${INVITE_COOKIE_NAME}=${encodeURIComponent(setupToken)}; path=/; max-age=3600; SameSite=Lax`; + } + }, [bootstrapStatus?.setupTokenSatisfied, isSignedIn, setupToken]); + + if (!isSignedIn) { + if (isBootstrapLoading || !bootstrapStatus?.setupOpen) { + return ( +
    + +
    + ); + } + + if ( + (bootstrapStatus.setupTokenRequired && + !bootstrapStatus.setupTokenSatisfied) || + bootstrapStatus.authSetup == null + ) { + return ( +
    + setSetupToken(nextSetupToken)} + /> +
    + ); + } + + return ( +
    + + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.25, ease: 'easeOut' }, + }), + }} + initial="enter" + animate="center" + exit="exit" + > + {bootstrapStep === 'welcome' && ( + { + setBootstrapStepWithTransition( + getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), + ); + }} + /> + )} + {bootstrapStep === 'email-account' && ( + { + setPendingAuthProvider(provider); + setBootstrapStepWithTransition( + getNextBootstrapStep(bootstrapStatus.authSetup, provider), + ); + }} + onUseEmailPassword={() => + setBootstrapStepWithTransition('email-password') + } + /> + )} + {bootstrapStep === 'email-password' && ( + setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-provider' && ( + { + if (provider === 'telegram' || provider === 'discord') { + return; + } + setPendingAuthProvider(provider); + setBootstrapStepWithTransition('auth-env-vars'); + }} + onBack={() => setBootstrapStepWithTransition('email-account')} + /> + )} + {bootstrapStep === 'auth-env-vars' && ( + undefined} + onBack={() => setBootstrapStepWithTransition('auth-provider')} + bootstrapMode={true} + setupToken={setupToken} + /> + )} + + +
    + ); + } + + if (!isSignedIn || !isAdmin) { + return null; + } + + if (isError) { + return ( +
    +

    + Unable to load setup. Redirecting you to Home. +

    + +
    + ); + } + + if (isLoading || !status) { + return ( +
    + +
    + ); + } + + return ( +
    + + ({ + opacity: 0, + y: direction === 'forward' ? 20 : -20, + }), + center: { + opacity: 1, + y: 0, + transition: { duration: 0.25, ease: 'easeOut' }, + }, + exit: (direction) => ({ + opacity: 0, + y: direction === 'forward' ? -20 : 20, + transition: { duration: 0.25, ease: 'easeOut' }, + }), + }} + initial="enter" + animate="center" + exit="exit" + > + {step === 'welcome' && } + {step === 'auth-provider' && ( + { + // Telegram and Discord are UI-only choices with no persisted + // auth provider and their own setup steps, so keep them on + // the pending-only path. + if (provider === 'telegram' || provider === 'discord') { + setPendingAuthProvider(provider); + goToStep('auth-env-vars'); + return; + } + + // Persist the chosen provider first; the choice only becomes + // pending and navigates on a successful save, so it survives a + // reload and a failed save never skips the chooser with an + // unsaved selection. + saveAuthProviderChoice.mutate({ provider }); + }} + onSkip={() => { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveAuthProviderChoice.isPending} + /> + )} + {step === 'auth-env-vars' && + (pendingAuthProvider === 'telegram' ? ( + { + setupSession.setCommunicationStepState('completed'); + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + ) : pendingAuthProvider === 'discord' ? ( + { + setupSession.setCommunicationStepState('completed'); + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingAuthProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + ) : ( + { + setPendingAuthProvider(null); + goToNextStep(); + }} + onBack={() => { + setPendingAuthProvider(null); + if (canGoBack) { + goToPreviousStep(); + return; + } + + goToStep('auth-provider'); + }} + bootstrapMode={false} + /> + ))} + {step === 'env-vars' && ( + { + setPendingModelProvider(null); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + onSelectedProviderChange={setPendingModelProvider} + /> + )} + {step === 'source-control-provider' && ( + { + saveSourceControlProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveSourceControlProviderChoice.isPending} + /> + )} + {step === 'source-control-config' && ( + { + setPendingSourceControlProvider(null); + goToStep('source-control-connect'); + }} + onBack={() => { + setPendingSourceControlProvider(null); + if (canGoBack) { + goToPreviousStep(); + return; + } + + goToStep('source-control-provider'); + }} + /> + )} + {step === 'source-control-connect' && ( + + )} + {step === 'compute-provider' && ( + { + saveComputeProviderChoice.mutate({ provider }); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + disabled={saveComputeProviderChoice.isPending} + /> + )} + {step === 'compute-config' && ( + { + setPendingComputeProvider(null); + goToNextStep(); + }} + onBack={ + canGoBack + ? () => { + setPendingComputeProvider(null); + goToPreviousStep(); + } + : undefined + } + /> + )} + {step === 'slack' && ( + { + setupSession.setCommunicationStepState('skipped'); + goToNextStep(); + }} + onBack={canGoBack ? goToPreviousStep : undefined} + returnPath={getSetupStepPath('slack')} + /> + )} + {step === 'repo-selection' && ( + + goToStep('compute-config', { revisit: true }) + } + onReviewComputeProvider={() => + goToStep('compute-provider', { revisit: true }) + } + onContinue={() => goToStep('invoke')} + onBack={canGoBack ? goToPreviousStep : undefined} + onSkip={() => { + setupSession.unlockPostOnboardingFlow(); + goToNextPostOnboardingStep(true); + }} + /> + )} + {step === 'invoke' && ( + + goToStep('compute-config', { revisit: true }) + } + onTryItOut={() => undefined} + /> + )} + + +
    + ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx index ba4b127a1..94708e1e0 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInferenceProvider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { @@ -69,17 +69,26 @@ export function StepInferenceProvider({ openRouterOauthErrorReason = null, onContinue, onBack, + onSelectedProviderChange, }: { modelSetup: SetupModelStatus; openRouterOauthStatus?: OpenRouterOauthEntryStatus | null; openRouterOauthErrorReason?: string | null; onContinue: () => void; onBack?: () => void; + onSelectedProviderChange?: (provider: SetupModelProviderId | null) => void; }) { const trpc = useTRPC(); const queryClient = useQueryClient(); const [selectedProvider, setSelectedProvider] = useState(null); + const selectProvider = useCallback( + (provider: SetupModelProviderId | null) => { + setSelectedProvider(provider); + onSelectedProviderChange?.(provider); + }, + [onSelectedProviderChange], + ); const [apiKey, setApiKey] = useState(''); const [connectionName, setConnectionName] = useState(''); const [additionalEnvValues, setAdditionalEnvValues] = useState< @@ -132,7 +141,7 @@ export function StepInferenceProvider({ useEffect(() => { if (openRouterOauthStatus === 'connected') { - setSelectedProvider('openrouter'); + selectProvider('openrouter'); toast.success( 'Connected to OpenRouter. Your new API key has been saved.', { @@ -144,7 +153,7 @@ export function StepInferenceProvider({ id: 'openrouter-oauth-result', }); } - }, [openRouterOauthStatus, openRouterOauthErrorReason]); + }, [openRouterOauthStatus, openRouterOauthErrorReason, selectProvider]); const selectedProviderStatus = useMemo( () => getProviderStatus(modelSetup, selectedProvider), @@ -298,7 +307,7 @@ export function StepInferenceProvider({ , ArrowRight: (props: SVGProps) => , Loader2: (props: SVGProps) => , diff --git a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx index 2b27445f8..cc9e7ba7b 100644 --- a/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepRepoSelection.tsx @@ -13,7 +13,6 @@ import { import { useCreateGitHubInstallation } from '@/hooks/github'; import { useRepositories } from '@/hooks/source-control'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; -import { DOCS_ENVIRONMENT_DEFINITION_URL } from '@/lib/docs'; import { areAllRepositoriesEmpty, getEmptyRepositories, @@ -37,6 +36,7 @@ import { Search, Spinner, Textarea, + Info, X, } from '@/components/system'; import { EnvironmentRepositorySelector } from '@/components/settings/environments/EnvironmentRepositorySelector'; @@ -383,29 +383,24 @@ export function StepRepoSelection({
    -
    +
    {!showForm && ( -

    - - {PRODUCT_NAME} excels by verifying its work. - {' '} - To do that, it needs to run your app locally, click around, take - screenshots. So we need to configure an environment, which includes - any required repos, dependencies, and related setup.{' '} - - See docs - - . -

    +
    + +

    + + {PRODUCT_NAME} needs environments to verify its work. + +
    + That lets it run your app locally, click around, make API calls, + take screenshots. +

    +
    )} -

    - Pick the repo(s) needed for the very first environment you want to set - up. +

    + Pick the repo(s) needed for the first env to set up. +
    + Roomote will install dependencies and figure it all out on its own.

    diff --git a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts index b43674804..0a5b7556b 100644 --- a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts +++ b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.client.test.ts @@ -2,6 +2,7 @@ import type { SetupAuthProviderId, SetupAuthStatus } from '@roomote/types'; import { getBootstrapAuthProvider, + getBootstrapStepPath, getBootstrapStepFromSetupStepParam, getBootstrapStepAfterWelcome, getNextBootstrapStep, @@ -98,4 +99,13 @@ describe('bootstrapFlow', () => { ).toBeNull(); expect(getBootstrapStepFromSetupStepParam(null)).toBeNull(); }); + + it('preserves setup query parameters when a bootstrap step changes', () => { + expect( + getBootstrapStepPath( + 'email-account', + new URLSearchParams('token=invite-token'), + ), + ).toBe('/setup?token=invite-token&step=email-account'); + }); }); diff --git a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts index cce6da521..7b09ef6c6 100644 --- a/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts +++ b/apps/web/src/app/(onboarding)/setup/bootstrapFlow.ts @@ -30,6 +30,15 @@ export function getBootstrapStepFromSetupStepParam( return step === 'auth-provider' || step === 'auth-env-vars' ? step : null; } +export function getBootstrapStepPath( + step: BootstrapStep, + currentParams: URLSearchParams, +): string { + const params = new URLSearchParams(currentParams); + params.set('step', step); + return `/setup?${params.toString()}`; +} + export function getNextBootstrapStep( authSetup: SetupAuthStatus | null | undefined, pendingAuthProvider?: SetupAuthProviderId | null, diff --git a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx index 20160ea8c..11d867462 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx @@ -151,9 +151,6 @@ function mockStatus(overrides: Partial> = {}) { }, ], }, - setupQualification: { - activeBlock: null, - }, setupNewState: { authProvider: null, modelProvider: null, diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts index 225b24acc..9f550a2d1 100644 --- a/apps/web/src/app/(onboarding)/setup/hooks.ts +++ b/apps/web/src/app/(onboarding)/setup/hooks.ts @@ -341,8 +341,6 @@ export function useSetupFlow( } const replayEntryVisit = isInitialReplayVisit(status); - const activeQualificationBlock = - status.setupQualification.activeBlock ?? null; // A communication provider that the user actually chose (either pending // in the current session or already saved). Runtime env vars alone must // not count as a choice so the chooser still renders and lets the user @@ -369,9 +367,8 @@ export function useSetupFlow( // before account creation, so skip the wizard's copy when this // browser session already saw it. return ( - activeQualificationBlock !== null || - (!replayEntryVisit && - (hasSeenSetupWelcome() || hasRealProgress(status))) + !replayEntryVisit && + (hasSeenSetupWelcome() || hasRealProgress(status)) ); case 'auth-provider': return communicationStepResolved || chosenAuthProvider !== null; @@ -409,8 +406,6 @@ export function useSetupFlow( } case 'source-control-connect': return status.sourceControlSetup.setupSatisfied; - case 'qualification-blocked': - return activeQualificationBlock === null; case 'compute-provider': return ( !hasStaleComputeProvider && diff --git a/apps/web/src/app/(onboarding)/setup/page.tsx b/apps/web/src/app/(onboarding)/setup/page.tsx index de21ae54e..0179b6513 100644 --- a/apps/web/src/app/(onboarding)/setup/page.tsx +++ b/apps/web/src/app/(onboarding)/setup/page.tsx @@ -1,781 +1,50 @@ -'use client'; - -import { AnimatePresence, motion } from 'motion/react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { - getSetupNewComputeProvisioningState, - isComputeCredentialField, - isSetupProvisionableComputeProvider, - type ComputeProvider, - type SourceControlProvider, -} from '@roomote/types'; - -import { useRedirectToSignIn } from '@/hooks/useSignInRedirect'; -import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser'; -import { - INVITE_COOKIE_NAME, - readInviteTokenFromDocumentCookie, -} from '@/lib/invite-cookie'; -import { - DEFAULT_SETUP_REDIRECT_PATH, - getSetupRedirectPath, -} from '@/lib/setup-status'; -import { useTRPC } from '@/trpc/client'; -import { Button, Spinner } from '@/components/system'; - -import { StepWelcome } from './StepWelcome'; -import { StepAuthEnvVars } from './StepAuthEnvVars'; -import { StepBootstrapAccount } from './StepBootstrapAccount'; -import { StepBootstrapEmailPassword } from './StepBootstrapEmailPassword'; -import { StepSetupToken } from './StepSetupToken'; -import { - StepAuthProvider, - type CommunicationProviderChoice, -} from './StepAuthProvider'; -import { StepTelegramSetup } from './StepTelegramSetup'; -import { StepDiscordSetup } from './StepDiscordSetup'; -import { StepInferenceProvider } from './StepInferenceProvider'; -import { StepComputeProvider } from './StepComputeProvider'; -import { StepComputeConfig } from './StepComputeConfig'; -import { StepSourceControlProvider } from './StepSourceControlProvider'; -import { StepSourceControlConfig } from './StepSourceControlConfig'; -import { StepSourceControlConnect } from './StepSourceControlConnect'; -import { StepQualificationBlocked } from './StepQualificationBlocked'; -import { StepCommunicationConnect } from './StepCommunicationConnect'; -import { StepInvoke } from './StepInvoke'; -import { useSetupFlow } from './hooks'; -import { StepRepoSelection, type SetupRetryReason } from './StepRepoSelection'; -import { getSetupStepPath } from './types'; -import { - getBootstrapAuthProvider, - getBootstrapStepFromSetupStepParam, - getBootstrapStepAfterWelcome, - getNextBootstrapStep, - type BootstrapStep, -} from './bootstrapFlow'; - -function getSetupRetryReason(status: { - onboardingFailed: boolean; - onboardingTaskStatus: string | null; - onboardingTaskPhase?: string | null; - matchingEnvironment: { id: string; name: string } | null; -}): SetupRetryReason | null { - if (!status.onboardingFailed) { - return null; - } - - if ( - (status.onboardingTaskStatus === 'completed' || - (status.onboardingTaskStatus === 'idle' && - status.onboardingTaskPhase === 'waiting_for_prompt')) && - status.matchingEnvironment === null - ) { - return 'no-environment'; - } - - if (status.onboardingTaskStatus === 'canceled') { - return 'task-canceled'; - } - - return 'task-failed'; -} - -function getInitialBootstrapStep(): BootstrapStep { - if (typeof window === 'undefined') { - return 'welcome'; - } - - return ( - getBootstrapStepFromSetupStepParam( - new URLSearchParams(window.location.search).get('step'), - ) ?? 'welcome' - ); -} - -const BOOTSTRAP_STEPS: readonly BootstrapStep[] = [ - 'welcome', - 'email-account', - 'email-password', - 'auth-provider', - 'auth-env-vars', -]; - -export default function SetupPage() { - const router = useRouter(); - const setupBootstrapOpen = useSetupBootstrapOpen(); - const { authStatus, isSignedIn, user } = useUser(); - const isAdmin = user?.isAdmin === true; - const shouldRedirectToSignIn = - authStatus === 'signed-out' && !setupBootstrapOpen; - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [bootstrapStep, setBootstrapStep] = useState( - getInitialBootstrapStep, - ); - const [bootstrapTransitionDirection, setBootstrapTransitionDirection] = - useState<'forward' | 'backward'>('forward'); - const bootstrapStepRef = useRef(bootstrapStep); - bootstrapStepRef.current = bootstrapStep; - const setBootstrapStepWithTransition = useCallback( - (nextStep: BootstrapStep) => { - const currentIndex = BOOTSTRAP_STEPS.indexOf(bootstrapStepRef.current); - const nextIndex = BOOTSTRAP_STEPS.indexOf(nextStep); - - if (nextStep !== bootstrapStepRef.current) { - setBootstrapTransitionDirection( - nextIndex >= currentIndex ? 'forward' : 'backward', - ); - } - - bootstrapStepRef.current = nextStep; - setBootstrapStep(nextStep); - }, - [], - ); - const [pendingAuthProvider, setPendingAuthProvider] = - useState(null); - const pendingSetupAuthProvider = - pendingAuthProvider === 'telegram' || pendingAuthProvider === 'discord' - ? null - : pendingAuthProvider; - const [pendingSourceControlProvider, setPendingSourceControlProvider] = - useState(null); - const [pendingComputeProvider, setPendingComputeProvider] = - useState(null); - const [setupToken, setSetupToken] = useState(() => { - if (typeof window === 'undefined') { - return null; - } - - // OAuth sign-in round-trips return to /setup without the ?token= query - // param, so fall back to the invite cookie to avoid bouncing the visitor - // back to the setup-token gate mid-flow. - return ( - new URLSearchParams(window.location.search).get('token') ?? - readInviteTokenFromDocumentCookie() - ); +import { compileMDX } from 'next-mdx-remote/rsc'; +import remarkGfm from 'remark-gfm'; +import { getDocsPage } from '@/lib/docs-content'; + +import { docsMdxComponents } from './DocsMdx'; +import SetupPageClient from './SetupPageClient'; +import { getSetupDocsPath, getSetupDocsStep } from './setup-docs'; + +export default async function SetupPage({ + searchParams, +}: { + searchParams: Promise<{ + authProvider?: string; + computeProvider?: string; + modelProvider?: string; + sourceControlProvider?: string; + step?: string; + }>; +}) { + const params = await searchParams; + const setupDocsStep = getSetupDocsStep(params.step ?? null); + const docsPath = getSetupDocsPath(setupDocsStep, { + authProvider: params.authProvider, + computeProvider: params.computeProvider, + modelProvider: params.modelProvider, + sourceControlProvider: params.sourceControlProvider, }); - const { data: bootstrapStatus, isLoading: isBootstrapLoading } = useQuery( - trpc.setupBootstrap.status.queryOptions( - setupToken ? { setupToken } : undefined, - { - enabled: !isSignedIn && setupBootstrapOpen, - staleTime: 5_000, - }, - ), - ); - const { - data: setupStatus, - isLoading: isSetupStatusLoading, - isError: isSetupStatusError, - } = useQuery( - trpc.setup.status.queryOptions(undefined, { - enabled: isSignedIn && isAdmin, - staleTime: 30_000, - }), - ); - const { - step, - transitionDirection, - entryContext, - goToStep, - goToPreviousStep, - goToNextStep, - canGoBack, - goToNextPostOnboardingStep, - status, - setupSession, - isLoading, - isError, - } = useSetupFlow({ - enabled: isSignedIn && isAdmin, - pendingAuthProvider: pendingSetupAuthProvider, - }); - const saveSourceControlProviderChoice = useMutation( - trpc.setupNew.saveSourceControlProviderChoice.mutationOptions({ - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: trpc.setupNew.status.queryKey(), - }); - setPendingSourceControlProvider(variables.provider); - goToStep('source-control-config'); - }, - onError: (error) => { - toast.error(error.message); - }, - }), - ); - const saveAuthProviderChoice = useMutation( - trpc.setupNew.saveAuthProviderChoice.mutationOptions({ - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: trpc.setupNew.status.queryKey(), - }); - // Only expose the choice as pending once the save has succeeded, so a - // failed request can never prematurely skip the chooser with an - // unsaved selection. - setPendingAuthProvider(variables.provider); - goToStep('auth-env-vars'); - }, - onError: (error) => { - toast.error(error.message); - }, - }), - ); - const saveComputeProviderChoice = useMutation( - trpc.setupNew.saveComputeProviderChoice.mutationOptions({ - onSuccess: async (_data, variables) => { - await queryClient.invalidateQueries({ - queryKey: trpc.setupNew.status.queryKey(), - }); - setPendingComputeProvider(variables.provider); - const selectedProvider = status?.computeSetup.providers.find( - (provider) => provider.provider === variables.provider, - ); - - // Credentialless providers stay on the short path even when they - // expose optional advanced settings later in Settings. - if ( - selectedProvider && - !selectedProvider.fields.some(isComputeCredentialField) - ) { - goToNextStep(); - return; - } - - // Pin the config step so already-satisfied credentials stay - // reviewable/editable instead of auto-skipping past them. - goToStep('compute-config', { revisit: true }); - }, - onError: (error) => { - toast.error(error.message); - }, - }), - ); - const hasPersistedSelectedSuggestedTasks = ( - status?.queuedOnboardingTasks ?? [] - ).some((task) => task.suggestionId !== null); - const bootstrapAuthProvider = getBootstrapAuthProvider( - bootstrapStatus?.authSetup, - pendingSetupAuthProvider, - ); - const setupRetryReason = status ? getSetupRetryReason(status) : null; - const selectedComputeProvider = status?.setupNewState.computeProvider; - const computeProvisioning = - status && - selectedComputeProvider && - isSetupProvisionableComputeProvider(selectedComputeProvider) - ? getSetupNewComputeProvisioningState( - status.setupNewState, - selectedComputeProvider, - ) - : null; - const shouldEvaluateSetupRedirect = isSignedIn && isAdmin; - const setupRedirectPath = - shouldEvaluateSetupRedirect && !isSetupStatusError && setupStatus != null - ? getSetupRedirectPath(setupStatus) - : null; - // When the user finishes setup from the invoke step, setup.status flips to - // completed before StepInvoke navigates to the onboarding task. Without this - // guard the effect below would replace('/') first and flash Home. - const observedIncompleteSetupRef = useRef(false); - useRedirectToSignIn(shouldRedirectToSignIn); - - useEffect(() => { - if (setupStatus != null && setupStatus.setupCompletedAt == null) { - observedIncompleteSetupRef.current = true; - } - }, [setupStatus]); - - useEffect(() => { - if (authStatus === 'signed-in' && !isAdmin) { - router.replace('/'); - return; - } - - if (!shouldEvaluateSetupRedirect) { - return; - } - - if (!isSetupStatusLoading && !isSetupStatusError && setupStatus != null) { - if ( - setupRedirectPath && - setupRedirectPath !== DEFAULT_SETUP_REDIRECT_PATH - ) { - router.replace(setupRedirectPath); - return; - } - - if (setupRedirectPath === null && !observedIncompleteSetupRef.current) { - router.replace('/'); - return; - } - } - - if (isError) { - router.replace('/'); - } - }, [ - authStatus, - isAdmin, - isError, - isSetupStatusError, - isSetupStatusLoading, - shouldEvaluateSetupRedirect, - router, - setupStatus, - setupRedirectPath, - ]); - - useEffect(() => { - if (isSignedIn || !bootstrapStatus) { - return; - } - - if (!bootstrapStatus.setupOpen) { - router.replace('/sign-in'); - return; - } - - const currentStep = bootstrapStepRef.current; - let nextStep = currentStep; - - if (currentStep !== 'welcome') { - if (currentStep === 'email-account' || currentStep === 'email-password') { - const nextBootstrapStep = getBootstrapStepAfterWelcome( - bootstrapStatus.authSetup, - ); - nextStep = - nextBootstrapStep === 'email-account' - ? currentStep - : nextBootstrapStep; - } else { - nextStep = getNextBootstrapStep( - bootstrapStatus.authSetup, - pendingSetupAuthProvider, - ); - } - } - - if (nextStep !== currentStep) { - setBootstrapStepWithTransition(nextStep); - } - }, [ - bootstrapStatus, - isSignedIn, - pendingSetupAuthProvider, - router, - setBootstrapStepWithTransition, - ]); - - useEffect(() => { - if (status?.setupNewState.authProvider) { - setPendingAuthProvider(null); - } - }, [status?.setupNewState.authProvider]); - - useEffect(() => { - // The setup token doubles as the system invite for the first admin - // account, so it has to reach the sign-up request as the invite cookie. - // Only persist it once the server confirms it, so a stale or mistyped - // ?token= can never clobber a still-valid cookie. - if (!isSignedIn && setupToken && bootstrapStatus?.setupTokenSatisfied) { - document.cookie = `${INVITE_COOKIE_NAME}=${encodeURIComponent(setupToken)}; path=/; max-age=3600; SameSite=Lax`; - } - }, [bootstrapStatus?.setupTokenSatisfied, isSignedIn, setupToken]); - - if (!isSignedIn) { - if (isBootstrapLoading || !bootstrapStatus?.setupOpen) { - return ( -
    - -
    - ); - } - - if ( - (bootstrapStatus.setupTokenRequired && - !bootstrapStatus.setupTokenSatisfied) || - bootstrapStatus.authSetup == null - ) { - return ( -
    - setSetupToken(nextSetupToken)} - /> -
    - ); - } - - return ( -
    - - ({ - opacity: 0, - y: direction === 'forward' ? 20 : -20, - }), - center: { - opacity: 1, - y: 0, - transition: { duration: 0.25, ease: 'easeOut' }, - }, - exit: (direction) => ({ - opacity: 0, - y: direction === 'forward' ? -20 : 20, - transition: { duration: 0.25, ease: 'easeOut' }, - }), - }} - initial="enter" - animate="center" - exit="exit" - > - {bootstrapStep === 'welcome' && ( - { - setBootstrapStepWithTransition( - getBootstrapStepAfterWelcome(bootstrapStatus.authSetup), - ); - }} - /> - )} - {bootstrapStep === 'email-account' && ( - { - setPendingAuthProvider(provider); - setBootstrapStepWithTransition( - getNextBootstrapStep(bootstrapStatus.authSetup, provider), - ); - }} - onUseEmailPassword={() => - setBootstrapStepWithTransition('email-password') - } - /> - )} - {bootstrapStep === 'email-password' && ( - setBootstrapStepWithTransition('email-account')} - /> - )} - {bootstrapStep === 'auth-provider' && ( - { - if (provider === 'telegram' || provider === 'discord') { - return; - } - setPendingAuthProvider(provider); - setBootstrapStepWithTransition('auth-env-vars'); - }} - onBack={() => setBootstrapStepWithTransition('email-account')} - /> - )} - {bootstrapStep === 'auth-env-vars' && ( - undefined} - onBack={() => setBootstrapStepWithTransition('auth-provider')} - bootstrapMode={true} - setupToken={setupToken} - /> - )} - - -
    - ); - } - - if (!isSignedIn || !isAdmin) { - return null; - } - - if (isError) { - return ( -
    -

    - Unable to load setup. Redirecting you to Home. -

    - -
    - ); - } - - if (isLoading || !status) { - return ( -
    - -
    - ); - } - - return ( -
    - - ({ - opacity: 0, - y: direction === 'forward' ? 20 : -20, - }), - center: { - opacity: 1, - y: 0, - transition: { duration: 0.25, ease: 'easeOut' }, - }, - exit: (direction) => ({ - opacity: 0, - y: direction === 'forward' ? -20 : 20, - transition: { duration: 0.25, ease: 'easeOut' }, - }), - }} - initial="enter" - animate="center" - exit="exit" - > - {step === 'welcome' && } - {step === 'auth-provider' && ( - { - // Telegram and Discord are UI-only choices with no persisted - // auth provider and their own setup steps, so keep them on - // the pending-only path. - if (provider === 'telegram' || provider === 'discord') { - setPendingAuthProvider(provider); - goToStep('auth-env-vars'); - return; - } - - // Persist the chosen provider first; the choice only becomes - // pending and navigates on a successful save, so it survives a - // reload and a failed save never skips the chooser with an - // unsaved selection. - saveAuthProviderChoice.mutate({ provider }); - }} - onSkip={() => { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveAuthProviderChoice.isPending} - /> - )} - {step === 'auth-env-vars' && - (pendingAuthProvider === 'telegram' ? ( - { - setupSession.setCommunicationStepState('completed'); - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - /> - ) : pendingAuthProvider === 'discord' ? ( - { - setupSession.setCommunicationStepState('completed'); - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingAuthProvider(null); - goToPreviousStep(); - } - : undefined - } - /> - ) : ( - { - setPendingAuthProvider(null); - goToNextStep(); - }} - onBack={() => { - setPendingAuthProvider(null); - if (canGoBack) { - goToPreviousStep(); - return; - } - - goToStep('auth-provider'); - }} - bootstrapMode={false} - /> - ))} - {step === 'env-vars' && ( - - )} - {step === 'source-control-provider' && ( - { - saveSourceControlProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveSourceControlProviderChoice.isPending} - /> - )} - {step === 'source-control-config' && ( - { - setPendingSourceControlProvider(null); - goToStep('source-control-connect'); - }} - onBack={() => { - setPendingSourceControlProvider(null); - if (canGoBack) { - goToPreviousStep(); - return; - } - - goToStep('source-control-provider'); - }} - /> - )} - {step === 'source-control-connect' && ( - - )} - {step === 'qualification-blocked' && - status.setupQualification.activeBlock ? ( - - ) : null} - {step === 'compute-provider' && ( - { - saveComputeProviderChoice.mutate({ provider }); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - disabled={saveComputeProviderChoice.isPending} - /> - )} - {step === 'compute-config' && ( - { - setPendingComputeProvider(null); - goToNextStep(); - }} - onBack={ - canGoBack - ? () => { - setPendingComputeProvider(null); - goToPreviousStep(); - } - : undefined - } - /> - )} - {step === 'slack' && ( - { - setupSession.setCommunicationStepState('skipped'); - goToNextStep(); - }} - onBack={canGoBack ? goToPreviousStep : undefined} - returnPath={getSetupStepPath('slack')} - /> - )} - {step === 'repo-selection' && ( - - goToStep('compute-config', { revisit: true }) - } - onReviewComputeProvider={() => - goToStep('compute-provider', { revisit: true }) - } - onContinue={() => goToStep('invoke')} - onBack={canGoBack ? goToPreviousStep : undefined} - onSkip={() => { - setupSession.unlockPostOnboardingFlow(); - goToNextPostOnboardingStep(true); - }} - /> - )} - {step === 'invoke' && ( - - goToStep('compute-config', { revisit: true }) - } - onTryItOut={() => undefined} - /> - )} - - -
    - ); + const docsPage = docsPath ? await getDocsPage(docsPath) : null; + const { content } = docsPage + ? await compileMDX({ + source: docsPage.source, + components: docsMdxComponents, + options: { mdxOptions: { remarkPlugins: [remarkGfm] } }, + }) + : { content: null }; + + const setupDocsContent = docsPage ? ( + <> +

    + {docsPage.title} +

    + {docsPage.description ? ( +

    {docsPage.description}

    + ) : null} + {content} + + ) : null; + + return ; } diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts new file mode 100644 index 000000000..97423c2f7 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -0,0 +1,98 @@ +import type { SetupStep } from './types'; + +type SetupDocsStep = SetupStep | 'email-account' | 'email-password'; + +type SetupDocsContext = { + authProvider?: string | null; + computeProvider?: string | null; + modelProvider?: string | null; + sourceControlProvider?: string | null; +}; + +const SETUP_DOC_PATHS: Record = { + welcome: null, + 'email-account': 'self-hosting', + 'email-password': 'self-hosting', + 'auth-provider': 'communications', + 'auth-env-vars': 'communications', + slack: 'providers/communications/slack', + 'env-vars': 'models', + 'source-control-provider': 'source-control', + 'source-control-config': 'source-control', + 'source-control-connect': 'source-control', + 'compute-provider': 'compute', + 'compute-config': 'compute', + 'repo-selection': 'environments', + invoke: 'how-roomote-works', +}; + +export function getSetupDocsStep(step: string | null): SetupDocsStep { + return step && step in SETUP_DOC_PATHS ? (step as SetupDocsStep) : 'welcome'; +} + +const AUTH_PROVIDER_DOC_PATHS: Record = { + discord: 'providers/communications/discord', + microsoft: 'providers/communications/microsoft-teams', + slack: 'providers/communications/slack', + telegram: 'providers/communications/telegram', +}; + +const COMPUTE_PROVIDER_DOC_PATHS: Record = { + blaxel: 'providers/compute/blaxel', + daytona: 'providers/compute/daytona', + docker: 'providers/compute/docker', + e2b: 'providers/compute/e2b', + modal: 'providers/compute/modal', +}; + +const MODEL_PROVIDER_DOC_PATHS: Record = { + litellm: 'providers/inference/litellm', + ollama: 'providers/inference/ollama', + 'openai-compatible': 'providers/inference/openai-compatible', + vllm: 'providers/inference/vllm', +}; + +const SOURCE_CONTROL_PROVIDER_DOC_PATHS: Record = { + ado: 'providers/source-control/azure-devops', + bitbucket: 'providers/source-control/bitbucket', + gitea: 'providers/source-control/gitea', + github: 'providers/source-control/github', + gitlab: 'providers/source-control/gitlab', +}; + +export function getSetupDocsPath( + step: SetupDocsStep, + context: SetupDocsContext = {}, +): string | null { + if (step === 'auth-env-vars') { + return ( + AUTH_PROVIDER_DOC_PATHS[context.authProvider ?? ''] ?? 'communications' + ); + } + + if (step === 'slack') { + return ( + AUTH_PROVIDER_DOC_PATHS[context.authProvider ?? ''] ?? + SETUP_DOC_PATHS[step] + ); + } + + if (step === 'env-vars') { + return MODEL_PROVIDER_DOC_PATHS[context.modelProvider ?? ''] ?? 'models'; + } + + if (step === 'source-control-config' || step === 'source-control-connect') { + return ( + SOURCE_CONTROL_PROVIDER_DOC_PATHS[context.sourceControlProvider ?? ''] ?? + 'source-control' + ); + } + + if (step === 'compute-config') { + return ( + COMPUTE_PROVIDER_DOC_PATHS[context.computeProvider ?? ''] ?? 'compute' + ); + } + + return SETUP_DOC_PATHS[step]; +} diff --git a/apps/web/src/app/(onboarding)/setup/types.ts b/apps/web/src/app/(onboarding)/setup/types.ts index aaf38d243..5ab697f05 100644 --- a/apps/web/src/app/(onboarding)/setup/types.ts +++ b/apps/web/src/app/(onboarding)/setup/types.ts @@ -38,10 +38,6 @@ const SETUP_STEP_DEFINITIONS = [ id: 'source-control-connect', title: 'Connect source control', }, - { - id: 'qualification-blocked', - title: 'Thanks for your interest!', - }, { id: 'compute-provider', title: 'Sandbox provider', diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3627258b3..b22fe5f30 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -327,6 +327,17 @@ opacity: 1; } } + + @keyframes squish-out { + 0% { + overflow: hidden; + max-width: 1000px; + } + 100% { + overflow: hidden; + max-width: 0; + } + } } /* Lucide icon defaults */ @@ -739,6 +750,69 @@ @apply my-2; } +/* In-app documentation is compiled from apps/docs MDX at build time. */ +.docs-content { + @apply text-[0.9375rem] text-foreground; +} + +.docs-content > * + * { + @apply mt-5; +} + +.docs-content h2, +.docs-content h3, +.docs-content h4 { + @apply scroll-mt-6 font-semibold tracking-tight; +} + +.docs-content h2 { + @apply mt-12 text-xl; +} + +.docs-content h3 { + @apply mt-8 text-lg; +} + +.docs-content h4 { + @apply mt-6 text-base font-semibold; +} + +.docs-content a { + @apply text-primary underline underline-offset-4 hover:text-primary/80; +} + +.docs-content ul { + @apply ml-6 list-disc space-y-2; +} + +.docs-content ol:not(:has(> li > p.font-medium)) { + @apply ml-6 list-decimal space-y-2; +} + +.docs-content :not(pre) > code { + @apply rounded bg-muted px-1.5 py-0.5 font-mono text-[0.8125rem]; +} + +.docs-content pre { + @apply overflow-x-auto rounded-lg border border-border bg-muted p-4 text-sm leading-6; +} + +.docs-content pre code { + @apply font-mono; +} + +.docs-content table { + @apply my-6 block max-w-full overflow-x-auto border-collapse text-left text-sm; +} + +.docs-content th { + @apply border-b border-border px-3 py-2 font-semibold; +} + +.docs-content td { + @apply border-b border-border px-3 py-2 align-top; +} + /* User messages have a different background color, requiring some overrides */ .chat-message.is-user [data-streamdown='code-block'] { @apply border-none; diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx index 75476c902..ae912aeb6 100644 --- a/apps/web/src/components/layout/UserMenu.tsx +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -19,10 +19,12 @@ import { export const UserMenu = ({ portalContainer, expanded = false, + menuSide = 'left', switchOrgRedirectPath, }: { portalContainer?: HTMLElement | null; expanded?: boolean; + menuSide?: 'top' | 'right' | 'bottom' | 'left'; switchOrgRedirectPath?: string; } = {}) => { const { isSignedIn, user } = useUser(); @@ -34,6 +36,7 @@ export const UserMenu = ({ return ( ['user']>; }) { @@ -91,7 +96,7 @@ function SignedInUserMenu({ diff --git a/apps/web/src/lib/docs-content.ts b/apps/web/src/lib/docs-content.ts new file mode 100644 index 000000000..8757d11b3 --- /dev/null +++ b/apps/web/src/lib/docs-content.ts @@ -0,0 +1,56 @@ +import { readFile } from 'node:fs/promises'; +import { resolve, sep } from 'node:path'; +import { cache } from 'react'; +import matter from 'gray-matter'; + +const DOCS_DIRECTORY = resolve(process.cwd(), '..', 'docs'); + +type DocsPage = { + slug: string; + title: string; + description?: string; + icon?: string; + source: string; +}; + +function getDocsPath(slug: string) { + const segments = slug.split('/'); + if ( + !slug || + segments.some((segment) => !segment || segment === '.' || segment === '..') + ) { + return null; + } + + const filePath = resolve(DOCS_DIRECTORY, `${slug}.mdx`); + return filePath.startsWith(`${DOCS_DIRECTORY}${sep}`) ? filePath : null; +} + +export const getDocsPage = cache( + async (slug: string): Promise => { + const filePath = getDocsPath(slug); + if (!filePath) { + return null; + } + + try { + const source = await readFile(filePath, 'utf8'); + const parsed = matter(source); + const frontmatter = parsed.data as { + title?: string; + description?: string; + icon?: string; + }; + + return { + slug, + title: frontmatter.title ?? slug.split('/').at(-1) ?? slug, + description: frontmatter.description, + icon: frontmatter.icon, + source: parsed.content.replace(/^\s*import\s+[\s\S]*?;\s*$/gm, ''), + }; + } catch { + return null; + } + }, +); diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index ba727b3f8..3dd29bd4a 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -125,7 +125,6 @@ vi.mock('@roomote/db/server', () => ({ slackInstallations: {}, slackUserMappings: {}, sql: vi.fn(), - syncSetupQualificationBlock: vi.fn(), users: {}, workItems: {}, })); diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 271eaa0b3..6c5a3f8b2 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -34,7 +34,6 @@ import { purgeSavedDeploymentWorkerImage, resolveTelegramRuntimeCredentials, resolveDiscordRuntimeCredentials, - syncSetupQualificationBlock, isChatGptSubscriptionConnected, isGitHubCopilotSubscriptionConnected, type DatabaseOrTransaction, @@ -177,15 +176,6 @@ type MutableQueuedSetupTask = PersistedQueuedSetupTask & { launchClaimedAt: Date | null; }; -type ActiveSetupQualificationBlock = { - reason: 'github_organization_required'; - email: string | null; - emailDomain: string | null; - githubAccountLogin: string | null; - githubAccountType: string | null; - lastBlockedAt: Date; -}; - async function assertSetupBootstrapOpen() { const bootstrapState = await getSetupBootstrapState(); @@ -194,10 +184,6 @@ async function assertSetupBootstrapOpen() { } } -function getSetupQualificationBlockErrorMessage() { - return 'Setup is currently limited to work email addresses. Use another work email or contact the team if this seems wrong.'; -} - async function getPersistedSetupNewState( executor: DatabaseOrTransaction = db, ): Promise { @@ -1287,31 +1273,6 @@ async function getMatchingEnvironmentSummary({ return null; } -async function getActiveSetupQualificationBlock( - auth: UserAuthSuccess, -): Promise { - await Promise.all([ - syncSetupQualificationBlock({ - userId: auth.userId, - reason: 'github_organization_required', - blocked: false, - }), - ]); - - return null; -} - -async function assertSetupQualificationNotBlocked(auth: UserAuthSuccess) { - const activeSetupQualificationBlock = - await getActiveSetupQualificationBlock(auth); - - if (!activeSetupQualificationBlock) { - return; - } - - throw new Error(getSetupQualificationBlockErrorMessage()); -} - export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { assertAdmin(auth); @@ -1356,8 +1317,6 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { isChatGptSubscriptionConnected(), isGitHubCopilotSubscriptionConnected(), ]); - const activeSetupQualificationBlock = - await getActiveSetupQualificationBlock(auth); let setupNewState = normalizeSetupNewState(baseStatus.setupNewState); if ( @@ -1498,9 +1457,6 @@ export async function getSetupNewStatusCommand(auth: UserAuthSuccess) { modelSetup, computeSetup, sourceControlSetup, - setupQualification: { - activeBlock: activeSetupQualificationBlock, - }, }; } @@ -2403,8 +2359,6 @@ export async function saveSetupNewSelectionCommand( }, ) { assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - const { userId } = auth; if (input.repositoryIds.length === 0) { @@ -2463,8 +2417,6 @@ export async function startSetupNewOnboardingTaskCommand( auth: UserAuthSuccess, ) { assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - const { userId } = auth; const startResult = await db.transaction(async (tx) => { await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('setup-new'))`); @@ -3022,8 +2974,6 @@ export async function saveSetupNewQueuedTasksCommand( }, ) { assertAdmin(auth); - await assertSetupQualificationNotBlocked(auth); - const setupNewState = await getPersistedSetupNewState(); if (!setupNewState.onboardingTaskId) { diff --git a/package.json b/package.json index 66e8104db..07c3a175b 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@better-auth/core>zod": "^4.3.6", "js-cookie": "3.0.7", "js-yaml": "4.2.0", + "gray-matter>js-yaml": "3.15.0", "read-yaml-file@1>js-yaml": "3.15.0", "nise>path-to-regexp": "8.4.0", "protobufjs": "7.6.3", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5703254f4..ea8a123e3 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,3 @@ // These can safely be imported in client or server environments. export * from './types'; -export * from './lib/setup-qualification'; diff --git a/packages/db/src/lib/__tests__/setup-qualification-blocks.test.ts b/packages/db/src/lib/__tests__/setup-qualification-blocks.test.ts deleted file mode 100644 index 2637c7cce..000000000 --- a/packages/db/src/lib/__tests__/setup-qualification-blocks.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { eq } from 'drizzle-orm'; - -import { db, setupQualificationBlocks, userFactory } from '../../server'; -import { syncSetupQualificationBlock } from '../setup-qualification-blocks'; - -describe('syncSetupQualificationBlock', () => { - it('does not fail when concurrent first-time blocked writes race', async () => { - const user = await userFactory.create({ - email: 'blocked@gmail.com', - }); - - const results = await Promise.all([ - syncSetupQualificationBlock({ - userId: user.id, - reason: 'github_organization_required', - blocked: true, - snapshot: { - githubAccountLogin: 'octocat', - githubAccountType: 'User', - }, - }), - syncSetupQualificationBlock({ - userId: user.id, - reason: 'github_organization_required', - blocked: true, - snapshot: { - githubAccountLogin: 'octocat', - githubAccountType: 'User', - }, - }), - ]); - - expect(results).toHaveLength(2); - expect(results[0]).toBeTruthy(); - expect(results[1]).toBeTruthy(); - - const rows = await db - .select({ - id: setupQualificationBlocks.id, - status: setupQualificationBlocks.status, - }) - .from(setupQualificationBlocks) - .where(eq(setupQualificationBlocks.userId, user.id)); - - expect(rows).toEqual([ - expect.objectContaining({ - status: 'blocked', - }), - ]); - }); -}); diff --git a/packages/db/src/lib/setup-qualification-blocks.ts b/packages/db/src/lib/setup-qualification-blocks.ts deleted file mode 100644 index a715d3197..000000000 --- a/packages/db/src/lib/setup-qualification-blocks.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { and, desc, eq } from 'drizzle-orm'; - -import { type DatabaseOrTransaction, db } from '../db'; -import { setupQualificationBlocks } from '../schema'; - -import type { - SetupQualificationBlockReason, - SetupQualificationBlockStatus, -} from './setup-qualification'; - -type SetupQualificationSnapshot = { - email?: string | null; - emailDomain?: string | null; - githubAccountLogin?: string | null; - githubAccountType?: string | null; -}; - -type SetupQualificationBlockRow = typeof setupQualificationBlocks.$inferSelect; - -function toSnapshotUpdate( - snapshot: SetupQualificationSnapshot, -): Pick< - typeof setupQualificationBlocks.$inferInsert, - 'email' | 'emailDomain' | 'githubAccountLogin' | 'githubAccountType' -> { - return { - email: snapshot.email ?? null, - emailDomain: snapshot.emailDomain ?? null, - githubAccountLogin: snapshot.githubAccountLogin ?? null, - githubAccountType: snapshot.githubAccountType ?? null, - }; -} - -async function findSetupQualificationBlock(params: { - database: DatabaseOrTransaction; - userId: string; - reason: SetupQualificationBlockReason; -}) { - return params.database.query.setupQualificationBlocks.findFirst({ - where: and( - eq(setupQualificationBlocks.userId, params.userId), - eq(setupQualificationBlocks.reason, params.reason), - ), - }); -} - -async function updateSetupQualificationBlockById(params: { - database: DatabaseOrTransaction; - id: string; - status?: SetupQualificationBlockStatus; - snapshot?: SetupQualificationSnapshot; - resolvedAt?: Date | null; - liftedByAdminUserId?: string | null; - liftedByAdminEmail?: string | null; - lastBlockedAt?: Date; -}) { - const now = new Date(); - const [updated] = await params.database - .update(setupQualificationBlocks) - .set({ - ...(params.status ? { status: params.status } : {}), - ...(params.snapshot ? toSnapshotUpdate(params.snapshot) : {}), - ...(params.resolvedAt !== undefined - ? { resolvedAt: params.resolvedAt } - : {}), - ...(params.liftedByAdminUserId !== undefined - ? { liftedByAdminUserId: params.liftedByAdminUserId } - : {}), - ...(params.liftedByAdminEmail !== undefined - ? { liftedByAdminEmail: params.liftedByAdminEmail } - : {}), - ...(params.lastBlockedAt ? { lastBlockedAt: params.lastBlockedAt } : {}), - updatedAt: now, - }) - .where(eq(setupQualificationBlocks.id, params.id)) - .returning(); - - return updated ?? null; -} - -export async function listSetupQualificationBlocks(params: { - db?: DatabaseOrTransaction; -}) { - const database = params.db ?? db; - - return database.query.setupQualificationBlocks.findMany({ - orderBy: [ - desc(setupQualificationBlocks.lastBlockedAt), - desc(setupQualificationBlocks.createdAt), - ], - }); -} - -export async function getSetupQualificationBlock(params: { - userId: string; - reason: SetupQualificationBlockReason; - db?: DatabaseOrTransaction; -}) { - const database = params.db ?? db; - - return findSetupQualificationBlock({ - database, - userId: params.userId, - reason: params.reason, - }); -} - -export async function syncSetupQualificationBlock(params: { - userId: string; - reason: SetupQualificationBlockReason; - blocked: boolean; - snapshot?: SetupQualificationSnapshot; - db?: DatabaseOrTransaction; -}) { - const database = params.db ?? db; - let existing = await findSetupQualificationBlock({ - database, - userId: params.userId, - reason: params.reason, - }); - const now = new Date(); - - if (params.blocked) { - if (!existing) { - const [created] = await database - .insert(setupQualificationBlocks) - .values({ - userId: params.userId, - reason: params.reason, - status: 'blocked', - ...toSnapshotUpdate(params.snapshot ?? {}), - firstBlockedAt: now, - lastBlockedAt: now, - updatedAt: now, - }) - .onConflictDoNothing({ - target: [ - setupQualificationBlocks.userId, - setupQualificationBlocks.reason, - ], - }) - .returning(); - - if (created) { - return created; - } - - existing = await findSetupQualificationBlock({ - database, - userId: params.userId, - reason: params.reason, - }); - - if (!existing) { - throw new Error('Failed to create setup qualification block.'); - } - } - - if (existing.status === 'lifted') { - return ( - (await updateSetupQualificationBlockById({ - database, - id: existing.id, - snapshot: params.snapshot, - })) ?? existing - ); - } - - return ( - (await updateSetupQualificationBlockById({ - database, - id: existing.id, - status: 'blocked', - snapshot: params.snapshot, - resolvedAt: null, - liftedByAdminUserId: null, - liftedByAdminEmail: null, - lastBlockedAt: now, - })) ?? existing - ); - } - - if (!existing || existing.status !== 'blocked') { - return existing ?? null; - } - - return ( - (await updateSetupQualificationBlockById({ - database, - id: existing.id, - status: 'passed', - snapshot: params.snapshot, - resolvedAt: now, - liftedByAdminUserId: null, - liftedByAdminEmail: null, - })) ?? existing - ); -} - -export async function liftSetupQualificationBlock(params: { - userId: string; - reason: SetupQualificationBlockReason; - liftedByAdminUserId: string; - liftedByAdminEmail: string; - db?: DatabaseOrTransaction; -}): Promise<{ - row: SetupQualificationBlockRow | null; - changed: boolean; -}> { - const database = params.db ?? db; - const existing = await findSetupQualificationBlock({ - database, - userId: params.userId, - reason: params.reason, - }); - - if (!existing) { - return { row: null, changed: false }; - } - - if (existing.status === 'lifted') { - return { row: existing, changed: false }; - } - - const updated = await updateSetupQualificationBlockById({ - database, - id: existing.id, - status: 'lifted', - resolvedAt: new Date(), - liftedByAdminUserId: params.liftedByAdminUserId, - liftedByAdminEmail: params.liftedByAdminEmail, - }); - - return { row: updated ?? existing, changed: true }; -} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 1adf1e855..eb7d6222c 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1695,6 +1695,9 @@ export const githubInstallationsRelations = relations( /** * setup_qualification_blocks + * + * N-1 rollback compatibility: this legacy table remains declared until the + * next release, even though no current application code reads or writes it. */ export const setupQualificationBlocks = pgTable( diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 67ac1d521..ddd77bc0a 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -76,7 +76,6 @@ export * from './lib/discord-runtime-credentials'; export * from './lib/router-debug-settings'; export * from './lib/pr-action-settings'; export * from './lib/setup-qualification'; -export * from './lib/setup-qualification-blocks'; export * from './lib/repositories'; export * from './lib/telemetry-ids'; export * from './lib/instance-report'; @@ -97,8 +96,6 @@ export { authVerifications, workItems, workItemsRelations, - setupQualificationBlocks, - setupQualificationBlocksRelations, tasks, tasksRelations, taskPins, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88378a0ae..b458f2753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,6 +28,7 @@ overrides: '@better-auth/core>zod': ^4.3.6 js-cookie: 3.0.7 js-yaml: 4.2.0 + gray-matter>js-yaml: 3.15.0 read-yaml-file@1>js-yaml: 3.15.0 nise>path-to-regexp: 8.4.0 protobufjs: 7.6.3 @@ -700,6 +701,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) @@ -712,6 +716,9 @@ importers: next: specifier: ^16.2.11 version: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-mdx-remote: + specifier: ^6.0.0 + version: 6.0.0(@types/react@19.2.10)(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -742,6 +749,9 @@ importers: remark-breaks: specifier: ^4.0.0 version: 4.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 remove-markdown: specifier: ^0.6.3 version: 0.6.3 @@ -3004,6 +3014,9 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -6443,6 +6456,10 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -6873,6 +6890,9 @@ packages: resolution: {integrity: sha512-YIFQQ1n2NSgwoB3sCe7RpkZzsrPxTMek6jc7wC9fXOm1wwfWAKja9gLOMEjlXOUd3LKV3o6Jci7n9BoHs5Z8Sg==} engines: {node: '>=14.18.0'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -7585,6 +7605,12 @@ packages: es-toolkit@1.47.0: resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -7703,9 +7729,24 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -7781,6 +7822,10 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -8109,6 +8154,10 @@ packages: resolution: {integrity: sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==} engines: {node: '>= 10.x'} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -8167,6 +8216,9 @@ packages: hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -8401,6 +8453,10 @@ packages: is-electron@2.2.2: resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -8685,6 +8741,10 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + knip@5.85.0: resolution: {integrity: sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg==} engines: {node: '>=18.18.0'} @@ -8946,6 +9006,10 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -8996,6 +9060,9 @@ packages: mdast-util-mdx-jsx@3.2.0: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} @@ -9092,12 +9159,30 @@ packages: micromark-extension-math@3.1.0: resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} @@ -9128,6 +9213,9 @@ packages: micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} @@ -9317,6 +9405,12 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next-mdx-remote@6.0.0: + resolution: {integrity: sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==} + engines: {node: '>=14', npm: '>=7'} + peerDependencies: + react: '>=16' + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -10104,6 +10198,20 @@ packages: react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -10153,6 +10261,9 @@ packages: rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} @@ -10185,6 +10296,9 @@ packages: remark-math@6.0.0: resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -10330,6 +10444,10 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} @@ -10637,6 +10755,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -11064,12 +11186,18 @@ packages: unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} unist-util-remove-position@5.0.0: resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -11167,6 +11295,9 @@ packages: vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + vfile-matter@5.0.1: + resolution: {integrity: sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -13305,6 +13436,36 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.14 + acorn: 8.17.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.17.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@mdx-js/react@3.1.1(@types/react@19.2.10)(react@19.2.4)': dependencies: '@types/mdx': 2.0.14 @@ -16654,6 +16815,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn@8.16.0: {} acorn@8.17.0: {} @@ -16870,6 +17035,8 @@ snapshots: dependencies: tslib: 2.8.1 + astring@1.9.0: {} + async-function@1.0.0: {} async@3.2.6: {} @@ -17269,6 +17436,8 @@ snapshots: dependencies: rfdc: 1.4.1 + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -17976,6 +18145,20 @@ snapshots: es-toolkit@1.47.0: {} + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.17.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + esbuild-register@3.6.0(esbuild@0.28.1): dependencies: debug: 4.4.3 @@ -18149,8 +18332,35 @@ snapshots: estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + estree-util-is-identifier-name@3.0.0: {} + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -18259,6 +18469,10 @@ snapshots: exsolve@1.1.0: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} extendable-error@0.1.7: {} @@ -18600,6 +18814,13 @@ snapshots: graphql@15.10.1: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.0 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + hachure-fill@0.5.2: {} has-bigints@1.1.0: {} @@ -18691,6 +18912,27 @@ snapshots: '@ungap/structured-clone': 1.3.1 unist-util-position: 5.0.0 + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -18973,6 +19215,8 @@ snapshots: is-electron@2.2.2: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -19253,6 +19497,8 @@ snapshots: khroma@2.1.0: {} + kind-of@6.0.3: {} + knip@5.85.0(@types/node@24.12.4)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 @@ -19494,6 +19740,8 @@ snapshots: underscore: 1.13.8 xmlbuilder: 10.1.1 + markdown-extensions@2.0.0: {} + markdown-table@3.0.4: {} marked@16.4.1: {} @@ -19623,6 +19871,16 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -19827,6 +20085,57 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -19840,6 +20149,18 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -19892,6 +20213,16 @@ snapshots: micromark-util-encode@2.0.1: {} + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + micromark-util-html-tag-name@2.0.1: {} micromark-util-normalize-identifier@2.0.1: @@ -20086,6 +20417,20 @@ snapshots: neo-async@2.6.2: {} + next-mdx-remote@6.0.0(@types/react@19.2.10)(react@19.2.4): + dependencies: + '@babel/code-frame': 7.29.7 + '@mdx-js/mdx': 3.1.1 + '@mdx-js/react': 3.1.1(@types/react@19.2.10)(react@19.2.4) + react: 19.2.4 + unist-util-remove: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + vfile-matter: 5.0.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -20964,6 +21309,35 @@ snapshots: - '@types/react' - redux + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -21035,6 +21409,14 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + rehype-sanitize@6.0.0: dependencies: '@types/hast': 3.0.4 @@ -21086,6 +21468,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -21296,6 +21685,11 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + secure-json-parse@4.0.0: {} semver@6.3.1: {} @@ -21745,6 +22139,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} strip-final-newline@2.0.0: {} @@ -22162,6 +22558,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -22171,6 +22571,12 @@ snapshots: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -22264,6 +22670,11 @@ snapshots: '@types/unist': 3.0.3 vfile: 6.0.3 + vfile-matter@5.0.1: + dependencies: + vfile: 6.0.3 + yaml: 2.9.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3