diff --git a/.agents/analytics.md b/.agents/analytics.md deleted file mode 100644 index bcd12c824..000000000 --- a/.agents/analytics.md +++ /dev/null @@ -1,375 +0,0 @@ -# Analytics - -Reference for the TanStack.com event taxonomy in Google Analytics 4. - -**GA4 property:** `G-JMT1Z50SPS` -**First-party proxy:** all hits route through `/_a/g/collect` on tanstack.com (see `netlify.toml`) -**Implementation:** [src/utils/analytics.ts](../src/utils/analytics.ts), [src/utils/analytics/events.ts](../src/utils/analytics/events.ts) - -## Design principles - -1. **Event names describe what happened.** Properties carry context. Adding a new partner placement is an enum value, not a new event. -2. **One event per outcome, not per intent.** No `_clicked`/`_attempted` events when an outcome event is going to fire anyway. -3. **No per-row events.** Counts and joined-string arrays as properties, not N rows per generation. -4. **Session context propagates.** Slow-changing props like `mode_used` and `idea_used` are stamped on every builder event so any breakdown works without joins. -5. **Typed registry.** Wrong props for an event = TypeScript error, not silent bad data. - ---- - -## The funnel - -Application Builder, four steps. No `OR` conditions, no overlapping event names. - -| # | Step | Event | Filter | -| --- | --------------------- | ------------------- | --------------------------------- | -| 1 | Landed on builder | `page_view` | `page_type = application_builder` | -| 2 | Got an analysis | `builder_analyzed` | — | -| 3 | Got a generation | `builder_generated` | — | -| 4 | Took action on result | `builder_activated` | — | - -Drop-off between any two steps is unambiguous. - -**Failure rates** are separate explorations, not branches off the funnel: - -- Analysis failure rate = `builder_failed[stage=analysis]` ÷ (`builder_analyzed` + `builder_failed[stage=analysis]`) -- Generation failure rate = `builder_failed[stage=generation]` ÷ (`builder_generated` + `builder_failed[stage=generation]`) -- Login wall rate = `builder_failed[stage=login_blocked]` ÷ `page_view[page_type=application_builder]` - ---- - -## Event reference (9 events) - -Every event automatically receives `page_location`, `page_path`, `page_title`, `page_type` from the analytics utility. - -### `page_view` - -Fires on initial load (auto from gtag config) and on every SPA navigation. - -| Prop | Type | Notes | -| --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | -| `page_location` | string | Full URL | -| `page_path` | string | Pathname | -| `page_title` | string | `document.title` | -| `page_type` | enum | `home`, `partners_index`, `partner_detail`, `blog_index`, `blog_post`, `docs`, `partners_embed`, `application_builder`, `page` | - ---- - -### `partner_viewed` - -A partner UI element scrolled ≥50% into the viewport. Fires once per element-mount per session (the underlying `IntersectionObserver` disconnects after first fire). - -| Prop | Type | Notes | -| ------------ | ------- | --------------------------------------------------- | -| `partner_id` | string | Stable partner identifier | -| `placement` | enum | See `PartnerPlacement` below | -| `slot_index` | number? | Position in the surface (0-indexed) when applicable | - -**Note on inflation:** when filters change on the partners directory, cards unmount/remount and `partner_viewed` re-fires. Treat session-unique impressions as the dedup'd metric (compute in BigQuery with `FIRST_VALUE(... PARTITION BY session_id, partner_id)`). - ---- - -### `partner_clicked` - -User clicked a partner UI element to navigate somewhere. - -| Prop | Type | Notes | -| ------------------ | ------- | -------------------------------------------------------------------------- | -| `partner_id` | string | | -| `placement` | enum | See `PartnerPlacement` below | -| `destination` | enum | `external` (partner's site) or `internal_detail` (our partner detail page) | -| `destination_host` | string? | Host of the destination URL when `external` | -| `slot_index` | number? | Position in the surface when applicable | - -CTR per placement = `partner_clicked` ÷ `partner_viewed` filtered to same `placement`. - ---- - -### `partner_filter_applied` - -User changed the filter state on the partners directory. - -| Prop | Type | Notes | -| ----------------- | -------------- | --------------------------------------------------------------------------------------------- | -| `change` | enum | `libraries_changed`, `status_changed`, `cleared_all` | -| `library_filters` | string | Comma-joined library ids in the filter, or empty string | -| `status_filter` | string \| null | `null` means "no status filter applied" — distinguishes from "user explicitly chose 'active'" | -| `result_count` | number | Number of partners visible after the filter | - ---- - -### `partner_inquiry_started` - -User clicked a "get in touch", "let's chat", or "become a partner" CTA. - -| Prop | Type | Notes | -| ----------- | ---- | ---------------------------------------------------------- | -| `placement` | enum | `partners_index_cta`, `library_callout`, `docs_right_rail` | - ---- - -### `builder_analyzed` - -Analysis API call succeeded. Outcome event — always preceded by user intent, no separate `_requested` event. - -| Prop | Type | Notes | -| ------------------------ | ------- | --------------------------------------------- | -| `mode_used` | enum | Session context: `lucky`, `confident`, `none` | -| `idea_used` | string | Session context: idea label, or `none` | -| `analysis_deployment` | string? | Inferred deploy target | -| `inferred_library_count` | number | | -| `inferred_partner_count` | number | | -| `feature_count` | number | | - ---- - -### `builder_generated` - -Generation API call succeeded. - -| Prop | Type | Notes | -| ----------------------- | ------- | ---------------------------------------------------------------------- | -| `mode_used` | enum | Session context | -| `idea_used` | string | Session context | -| `final_deployment` | string? | The chosen deploy target on the final result | -| `final_package_manager` | string | `pnpm`, `npm`, `yarn`, `bun` | -| `final_library_count` | number | | -| `final_partner_count` | number | | -| `final_addon_count` | number | | -| `library_ids` | string | Comma-joined LibraryIds — use `SPLIT()` in BigQuery for top-N analysis | -| `partner_ids` | string | Comma-joined | -| `addon_ids` | string | Comma-joined | - ---- - -### `builder_failed` - -Single umbrella event for analysis failures, generation failures, and login-wall blocks. Use the `stage` prop to distinguish. - -| Prop | Type | Notes | -| --------------------------------- | ------- | -------------------------------------------------------------------------------------------- | -| `mode_used` | enum | Session context | -| `idea_used` | string | Session context | -| `stage` | enum | `analysis`, `generation`, `login_blocked` | -| `error_message` | string? | Free-form error message — high cardinality, don't register as a dimension; query in BigQuery | -| `retry_after` | number? | Seconds until retry permitted (login_blocked only) | -| `anonymous_generations_remaining` | number? | When the failure was rate-limit related | - ---- - -### `builder_activated` - -User took an action on the generated result. Single event with `action` prop covers all post-generation actions. - -| Prop | Type | Notes | -| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `mode_used` | enum | Session context | -| `idea_used` | string | Session context | -| `action` | enum | See `BuilderAction` below | -| `surface` | enum | `result_panel` (main builder UI) or `deploy_dialog` | -| `provider` | string? | Deploy provider when applicable: `vercel`, `netlify`, `cloudflare` | -| `automatic` | boolean | `true` for system-driven actions (e.g., deploy_dialog auto-redirect countdown). Filter to `false` for true user click rates. | - -**Important:** automatic prompt-copies that fire as a side-effect of generation do NOT emit `builder_activated`. Only user-driven actions count as activation. - ---- - -## Enums - -### `PartnerPlacement` - -| Value | Where it appears | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `directory` | Partner cards in `/partners` index | -| `detail` | Partner detail page CTA | -| `docs_rail` | Right rail on docs pages — partner cards AND the "Become a Partner" link both fire with this placement (event name distinguishes) | -| `blog_rail` | Right rail on blog pages | -| `grid` | Generic partners grid (fallback) | -| `home_grid` | Home page social-proof grid | -| `library_grid` | Library page partners section — filter `page_path` to know which library | -| `embed_grid` | Partners embed view | -| `docs_strip` | Mobile partner strip in docs | -| `ecosystem_game` | 3D ecosystem game islands | -| `partners_index_cta` | "Get in touch" mailto on `/partners` | -| `library_callout` | "Let's chat" callout per library | - -### `BuilderAction` - -| Value | Means | -| -------------------------- | ---------------------------------------------------------------------- | -| `copy_prompt` | User clicked a copy button on the prompt | -| `deploy` | Started a deploy through the deploy dialog | -| `clone_repo` | Cloned the GitHub repo | -| `open_codex` | Opened the result in Codex | -| `open_claude` | Opened the result in Claude | -| `open_cursor` | Opened the result in Cursor | -| `download` | Downloaded the project as a zip | -| `open_advanced` | Opened the advanced builder editor | -| `netlify_start` | Started a Netlify deploy from the result | -| `provider_redirect_manual` | User clicked through to deploy provider | -| `provider_redirect_auto` | Countdown auto-redirected user to deploy provider (`automatic = true`) | -| `open_repo` | Opened the project repo from the deploy dialog | - ---- - -## Session context - -`mode_used` and `idea_used` are tracked in the builder hook and stamped on **every** builder event. This means any builder event can be sliced by mode or idea without session joins. - -**`mode_used`** transitions: - -- `none` → `lucky` when user clicks "I'm feeling lucky" -- `none` → `confident` when user clicks "I'm feeling confident" - -**`idea_used`** transitions: - -- `none` → idea label string when user picks a suggested idea -- Reset back to `none` if user clears or types fresh input (TBD — currently sticks for the session) - ---- - -## Custom dimensions to register in GA4 - -Admin → Custom definitions → Create custom dimension. **Event scope** for all of these. Without registration, they're stored in BigQuery export but invisible in the GA4 UI. - -| Dimension name | API name | Used on | -| --------------------- | ----------------------- | -------------------------------------------------------- | -| Placement | `placement` | partner_viewed, partner_clicked, partner_inquiry_started | -| Partner ID | `partner_id` | partner_viewed, partner_clicked | -| Mode used | `mode_used` | all builder events | -| Idea used | `idea_used` | all builder events | -| Action | `action` | builder_activated | -| Surface | `surface` | builder_activated | -| Stage | `stage` | builder_failed | -| Final deployment | `final_deployment` | builder_generated | -| Final package manager | `final_package_manager` | builder_generated | -| Final library count | `final_library_count` | builder_generated | -| Final partner count | `final_partner_count` | builder_generated | -| Page type | `page_type` | all events | - -12 dimensions. Well under the 50-dimension event-scoped limit. - -**Don't register:** `error_message`, `library_ids`, `partner_ids`, `addon_ids`, `destination_host`. High cardinality. Query in BigQuery. - ---- - -## Common GA4 queries - -### "What's our funnel completion rate?" - -**Explore → Funnel exploration**, four steps as defined above. Open funnel. Show elapsed time. - -### "Lucky vs Confident: which mode converts better?" - -Same funnel, breakdown dropdown = `mode_used`. Compare side by side. - -### "Which deploy targets do successful generations land on?" - -**Explore → Free-form**, dimension = `final_deployment`, metric = event count of `builder_generated`. - -### "What % of users hit the login wall?" - -Free-form, filter `event_name = builder_failed`, breakdown by `stage`. - -### "Which placement converts best for partner discovery?" - -Free-form, filter `event_name = partner_clicked OR partner_viewed`, breakdown by `placement`. Compute CTR yourself: clicks ÷ views per placement. - -### "Top 10 libraries in completed generations" - -Requires BigQuery — `library_ids` is high-cardinality. Sample query: - -```sql -SELECT - library_id, - COUNT(*) AS generations -FROM `tanstack.analytics_*.events_*`, -UNNEST(SPLIT((SELECT value.string_value - FROM UNNEST(event_params) - WHERE key = 'library_ids'), ',')) AS library_id -WHERE event_name = 'builder_generated' - AND _TABLE_SUFFIX BETWEEN '20260101' AND '20260131' -GROUP BY library_id -ORDER BY generations DESC -LIMIT 10 -``` - ---- - -## BigQuery export - -**Strongly recommended.** Free at our event volume. Without it, anything beyond stock reports is painful. - -Setup: - -1. GA4 Admin → BigQuery Links → Link -2. Pick a GCP project, daily export, US multi-region -3. Tables appear at `tanstack.analytics_.events_YYYYMMDD` after ~24h - -Once enabled, all event properties are queryable — including the ones not registered as custom dimensions. Use SQL for everything dimensional or aggregate-heavy. Use the GA4 UI for funnel exploration and headline numbers. - ---- - -## Adding new events - -Anything additive should not require schema migration of the existing taxonomy. - -**Add a new partner placement:** - -1. Add the value to `PartnerPlacement` in [src/utils/analytics/events.ts](../src/utils/analytics/events.ts) -2. Use it at the call site -3. (Optional) update the placement table in this doc - -**Add a new builder action:** - -1. Add the value to `BuilderAction` -2. Use it at the `builder_activated` call site - -**Add a new event:** - -1. Add a new union member to `AnalyticsEvent` with its prop interface -2. Call `trackEvent({ name: '...', props: { ... } })` -3. Register relevant breakdown props as custom dimensions in GA4 admin -4. Document the event in this file - -**Don't:** add new properties to existing events without updating both the type definition and this doc. Schema drift in analytics events is the slowest bug to detect. - ---- - -## Code locations - -| File | Purpose | -| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -| [src/utils/analytics.ts](../src/utils/analytics.ts) | `trackEvent`, `useTrackedImpression`, `trackPageView`, `getPageType` | -| [src/utils/analytics/events.ts](../src/utils/analytics/events.ts) | Typed event registry — discriminated union of all events | -| [src/utils/analytics/providers/google.ts](../src/utils/analytics/providers/google.ts) | gtag wrapper | -| [src/utils/analytics/types.ts](../src/utils/analytics/types.ts) | Provider interface | -| [src/routes/\_\_root.tsx](../src/routes/__root.tsx) | gtag bootstrap, `PageViewTracker` | -| [netlify.toml](../netlify.toml) | First-party proxy redirects | - ---- - -## Migration history - -### 2026-05 — schema v2 - -Collapsed 28 events into 9. Removed `application_starter_*` event family. Replaced with `builder_*` events. Mode and idea selection moved from standalone events into session-context props on every builder event. - -**Events removed entirely** (no replacement): - -- `application_starter_library_toggled`, `_integration_toggled`, `_package_manager_toggled`, `_toolchain_toggled` — config exploration depth no longer tracked. Final config is on `builder_generated`. -- `application_starter_continue_clicked`, `_generate_clicked` — intent implied by outcome events. -- `application_starter_login_clicked`, `_value_copied` (auto), `_builder_result_applied`, `_final_partner_in_prompt`, `_final_addon_in_prompt`. - -**Events folded into others:** - -- `application_starter_action_clicked` (mode_selected) → `mode_used` prop on subsequent events -- `application_starter_idea_selected` → `idea_used` prop on subsequent events -- `application_starter_login_required` → `builder_failed[stage=login_blocked]` -- `application_starter_value_copied` (user trigger) → `builder_activated[action=copy_prompt]` -- All other `application_starter_action_clicked` calls → `builder_activated` -- `partner_card_clicked`, `partner_click` → `partner_clicked` -- `partner_impression`, `partner_detail_viewed` → `partner_viewed` -- `partners_filter_changed` → `partner_filter_applied` -- `partner_inquiry_clicked`, `become_partner_clicked` → `partner_inquiry_started` - -Historical data with old event names is still queryable in GA4 and BigQuery. Cutover date: see git log for the migration commit. diff --git a/.agents/index.md b/.agents/index.md deleted file mode 100644 index 01316aeb9..000000000 --- a/.agents/index.md +++ /dev/null @@ -1,19 +0,0 @@ -# Agent Guidelines - -TanStack.com marketing site built with TanStack Start. - -## Essentials - -- Package manager: `pnpm` -- Run `pnpm test` before commits not after every tiny edit -- Don't run builds or tests after every change. This is a visual site; assume changes work unless reported otherwise. -- Rely on `tsc` or your built-in LSP integration (hopefully you have this) -- **Typesafety is paramount.** Never cast types; fix at source instead. See [typescript.md](./typescript.md). - -## Topic Guides - -- [TypeScript Conventions](./typescript.md): Type inference, casting rules, generic naming -- [TanStack Patterns](./tanstack-patterns.md): Loaders, server functions, environment shaking -- [UI Style Guide](./ui-style.md): Visual design principles for 2026 -- [Workflow](./workflow.md): Build commands, debugging, Playwright -- [Analytics](./analytics.md): GA4 event taxonomy, funnel definition, custom dimensions diff --git a/.agents/tanstack-patterns.md b/.agents/tanstack-patterns.md deleted file mode 100644 index d0ff4e278..000000000 --- a/.agents/tanstack-patterns.md +++ /dev/null @@ -1,76 +0,0 @@ -# TanStack Patterns - -## loaderDeps Must Be Specific - -Only include properties actually used in the loader. This ensures proper cache invalidation. - -```typescript -// Bad: includes everything -loaderDeps: ({ search }) => search, -loader: async ({ deps }) => { - await fetchData({ page: deps.page, pageSize: deps.pageSize }) -} - -// Good: only what's used -loaderDeps: ({ search }) => ({ - page: search.page, - pageSize: search.pageSize, -}), -loader: async ({ deps }) => { - await fetchData({ page: deps.page, pageSize: deps.pageSize }) -} -``` - -## Loaders Are Isomorphic - -Loaders run on both server and client. They cannot directly access server-only APIs. - -```typescript -// Bad: direct server API access -loader: async () => { - const data = await fs.readFile('data.json') - return { data } -} - -// Good: call a server function -loader: async () => { - const data = await serverFn({ data: { id: '123' } }) - return { data } -} -``` - -## Environment Shaking - -TanStack Start strips any code not referenced by a `createServerFn` handler from the client build. - -- Server-only code (database, fs) is automatically excluded from client bundles -- Only code inside `createServerFn` handlers goes to server bundles -- Code outside handlers is included in both bundles - -## Importing Server Functions - -Server functions wrapped in `createServerFn` can be imported statically. Never use dynamic imports for server-only code in components. - -```typescript -// Bad: dynamic import causes bundler issues -const rolesQuery = useQuery({ - queryFn: async () => { - const { listRoles } = await import('~/utils/roles.server') - return listRoles({ data: {} }) - }, -}) - -// Good: static import -import { listRoles } from '~/utils/roles.server' - -const rolesQuery = useQuery({ - queryFn: async () => listRoles({ data: {} }), -}) -``` - -## Server-Only Import Rules - -1. `createServerFn` wrappers can be imported statically anywhere -2. Direct server-only code (database clients, fs) must only be imported: - - Inside `createServerFn` handlers - - In `*.server.ts` files diff --git a/.agents/typescript.md b/.agents/typescript.md deleted file mode 100644 index e63301ec4..000000000 --- a/.agents/typescript.md +++ /dev/null @@ -1,45 +0,0 @@ -# TypeScript Conventions - -## Avoid Type Casting - -Never cast types unless absolutely necessary. This includes: - -- Manual generic type parameters (e.g., ``) -- Type assertions using `as` -- Type assertions using `satisfies` - -## Prefer Type Inference - -Infer types by going up the logical chain: - -1. **Schema validation** as source of truth (Convex, Zod, etc.) -2. **Type inference** from function return types, API responses -3. **Fix at source** (schema, API definition, function signature) rather than casting at point of use - -```typescript -// Bad -const result = api.getData() as MyType -const value = getValue() - -// Good -const result = api.getData() // Type inferred from return type -const value = getValue() // Type inferred from implementation -``` - -## Generic Type Parameter Naming - -All generic type parameters must be prefixed with `T`. - -```typescript -// Bad -function withCapability( - handler: (user: AuthUser, ...args: Args) => R, -) { ... } - -// Good -function withCapability( - handler: (user: AuthUser, ...args: TArgs) => TReturn, -) { ... } -``` - -Common names: `T`, `TArgs`, `TReturn`, `TData`, `TError`, `TKey`, `TValue` diff --git a/.agents/ui-style.md b/.agents/ui-style.md deleted file mode 100644 index d696b3346..000000000 --- a/.agents/ui-style.md +++ /dev/null @@ -1,52 +0,0 @@ -# UI Style Guide 2026 - -## Layout - -- Fewer, well-defined containers over many small sections -- Generous spacing creates separation before adding visual effects -- Cards are acceptable when they express grouping or hierarchy - -## Corners - -- Rounded corners are standard -- Subtle radius values that feel intentional, not playful -- Avoid sharp 90-degree corners unless intentionally industrial - -## Shadows and Depth - -- Soft, low-contrast, diffused shadows -- Shadows imply separation, not elevation theatrics -- No heavy drop shadows or strong directional lighting -- One to two shadow layers max - -## Cards - -- Cards should feel grounded, not floating -- Light elevation, border plus shadow, or surface contrast -- Don't overuse cards as a default layout primitive - -## Color and Surfaces - -- Soft neutrals, off-whites, warm grays -- Surface contrast or translucency instead of strong outlines -- Glass/frosted effects acceptable when subtle and accessible - -## Interaction - -- Micro transitions reinforce spatial relationships -- Hover/focus states feel responsive, not animated -- No excessive motion or springy effects - -## Typography - -- Strong headings, calm body text -- No visual noise around content - -## What to Avoid - -- Chunky shadows -- Overly flat, sterile layouts -- Neumorphism as primary style -- Over-designed card grids - -**Summary: If depth does not improve comprehension, remove it.** diff --git a/.agents/workflow.md b/.agents/workflow.md deleted file mode 100644 index 07838ff50..000000000 --- a/.agents/workflow.md +++ /dev/null @@ -1,46 +0,0 @@ -# Workflow - -## Build Commands - -- `pnpm test`: Run at end of task batches -- `pnpm build`: Only for build/bundler issues or verifying production output -- `pnpm lint`: Check for code issues -- `dev` runs indefinitely in watch mode - -Don't build after every change. This is a visual site; assume changes work. - -## Dev Authentication - -The dev server uses the real production database and OAuth. There are no auth bypasses. - -To authenticate a dev session, the human must run: - -```sh -pnpm auth:login -``` - -This opens `tanstack.com`, completes OAuth, and saves `DEV_SESSION_TOKEN` to `.env.local`. The dev server then auto-injects that token as a session cookie on startup. - -If the user asks for help with anything requiring authentication (account pages, admin, mutations) and `DEV_SESSION_TOKEN` is not set in `.env.local`, tell them to run `pnpm auth:login` first and restart the dev server. - -## Debugging Visual Issues - -When something doesn't work or look right: - -1. Use Playwright MCP to view the page and debug visually -2. Use `pnpm build` only for build/bundler issues -3. Use `pnpm lint` for code issues - -## Playwright - -Playwright is available via `npx playwright` and should be used freely to interact with the running dev server like a human would. This includes testing, debugging, visual verification, design iteration, and general development workflows. - -Since the dev server runs with a real authenticated session (via `DEV_SESSION_TOKEN`), Playwright has full access to gated pages, account features, admin areas, and authenticated mutations — exactly as the signed-in user would. - -Common uses: - -- `npx playwright screenshot ` — capture current state of any page -- Navigate to a page, interact with elements, re-screenshot to verify changes -- Debug layout/visual issues without needing a human to look -- Verify authenticated flows end-to-end (account, showcase submissions, admin, etc.) -- Iterate on UI by screenshotting, editing, screenshotting again diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 7e34cf155..000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "web", - "runtimeExecutable": "pnpm", - "runtimeArgs": ["dev"], - "port": 3000 - }, - { - "name": "db-studio", - "runtimeExecutable": "pnpm", - "runtimeArgs": ["db:studio"], - "port": 4983 - } - ] -} diff --git a/.claude/tanstack-patterns.md b/.claude/tanstack-patterns.md deleted file mode 120000 index 7335c6c22..000000000 --- a/.claude/tanstack-patterns.md +++ /dev/null @@ -1 +0,0 @@ -../.agents/tanstack-patterns.md \ No newline at end of file diff --git a/.claude/typescript.md b/.claude/typescript.md deleted file mode 120000 index bf4e25139..000000000 --- a/.claude/typescript.md +++ /dev/null @@ -1 +0,0 @@ -../.agents/typescript.md \ No newline at end of file diff --git a/.claude/ui-style.md b/.claude/ui-style.md deleted file mode 120000 index f59b7412f..000000000 --- a/.claude/ui-style.md +++ /dev/null @@ -1 +0,0 @@ -../.agents/ui-style.md \ No newline at end of file diff --git a/.claude/workflow.md b/.claude/workflow.md deleted file mode 120000 index c19e63b75..000000000 --- a/.claude/workflow.md +++ /dev/null @@ -1 +0,0 @@ -../.agents/workflow.md \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..af7fa28f8 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": ["react-app"], + "parser": "@typescript-eslint/parser", + "plugins": ["react-hooks"] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index fd99b3b17..000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: tannerlinsley diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml deleted file mode 100644 index b336e672a..000000000 --- a/.github/workflows/autofix.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: autofix.ci # needed to securely identify the workflow - -on: - pull_request: - push: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - autofix: - name: autofix - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - name: Setup Tools - uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main - - name: Fix formatting - run: pnpm format - - name: Apply fixes - uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 - with: - commit-message: 'ci: apply automated fixes' diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml deleted file mode 100644 index 4771fdd25..000000000 --- a/.github/workflows/docs-links.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Docs Links - -on: - workflow_dispatch: - schedule: - - cron: '0 9 * * *' - -permissions: - contents: read - -jobs: - docs-links: - name: Docs Links - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Setup Tools - uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main - - - name: Check docs menu links - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set +e - pnpm docs:links:check -- --json docs-menu-link-report.json - status=$? - - if [ -f docs-menu-link-report.json ]; then - { - echo "### Docs menu link report" - echo - echo '```json' - cat docs-menu-link-report.json - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - fi - - exit $status diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 000000000..0a534520b --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,25 @@ +name: PR + +on: + pull_request: + +jobs: + pr: + name: PR + runs-on: ubuntu-latest + steps: + - name: Git Checkout + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - name: Install Packages + run: pnpm install --frozen-lockfile + - name: Run Lint + run: pnpm lint + - name: Run Build + run: pnpm build diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml deleted file mode 100644 index d4497f807..000000000 --- a/.github/workflows/pr.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: PR - -on: - pull_request: - -permissions: - contents: read - -jobs: - pr: - name: PR - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - name: Setup Tools - uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main - - name: Run Build - run: pnpm build - - name: Run Tests - run: pnpm test diff --git a/.github/workflows/update-tanstack-deps.yml b/.github/workflows/update-tanstack-deps.yml deleted file mode 100644 index 7d4ad61d9..000000000 --- a/.github/workflows/update-tanstack-deps.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Update TanStack Dependencies - -on: - workflow_dispatch: - schedule: - # Run weekly on Sundays at 10:00 AM UTC - - cron: '0 10 * * 0' - -jobs: - update-deps: - name: Update TanStack Dependencies - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Git Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - # This scheduled job commits dependency updates back to the branch. - persist-credentials: true - - - name: Setup pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - - - name: Setup Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .nvmrc - cache: pnpm - - - name: Install Packages - run: pnpm install --frozen-lockfile - - - name: Update TanStack Dependencies - run: pnpm up "@tanstack/*" --latest - - - name: Check for Changes - id: git-check - run: | - git status --porcelain . - if [[ -z $(git status --porcelain .) ]]; then - echo "No changes detected" - echo "changes=false" >> $GITHUB_OUTPUT - else - echo "Changes detected" - echo "changes=true" >> $GITHUB_OUTPUT - fi - - - name: Run Lint - if: steps.git-check.outputs.changes == 'true' - run: pnpm lint - - - name: Run Build - if: steps.git-check.outputs.changes == 'true' - run: pnpm build - - - name: Commit and Push Changes - if: steps.git-check.outputs.changes == 'true' - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . - git commit -m "chore: update @tanstack/* dependencies" - git push diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml deleted file mode 100644 index 1d4088db8..000000000 --- a/.github/workflows/zizmor.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: GitHub Actions Security Analysis - -on: - push: - branches: [main] - pull_request: - branches: ['**'] - -permissions: {} - -jobs: - zizmor: - name: Run zizmor - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Run zizmor - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 - with: - advanced-security: false - annotations: true diff --git a/.gitignore b/.gitignore index 7021fe7e7..d3387e00c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,18 @@ node_modules package-lock.json yarn.lock -drizzle/migrations -.tanstack .DS_Store .cache .env -.env.* .vercel .output .vinxi -.tanstack-start/build -.nitro/* -.netlify/* -.wrangler/* /build/ /api/ /server/build /public/build - +.vinxi # Sentry Config File .env.sentry-build-plugin -dist -.vscode/ -.env.local - -# Content Collections generated files -.content-collections - -test-results -.claude/CLAUDE.md -.claude/worktrees -.eslintcache -.tsbuildinfo -src/routeTree.gen.ts -.og-preview/ diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 63860b5bd..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm husky diff --git a/.nvmrc b/.nvmrc index d9b042470..c12134be3 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v25.9.0 +v20.15.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json deleted file mode 100644 index ac154e249..000000000 --- a/.oxfmtrc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "semi": false, - "singleQuote": true, - "trailingComma": "all", - "printWidth": 80, - "sortPackageJson": false, - "ignorePatterns": [ - "**/api", - "**/build", - "**/public", - "pnpm-lock.yaml", - "routeTree.gen.ts", - "src/blog/tanstack-db-0.1-the-embedded-client-database-for-tanstack-query.md", - ".content-collections", - ".claude", - "dist/**", - ".output/**" - ] -} diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index f6467d88c..000000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,204 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript"], - "categories": { - "correctness": "off" - }, - "options": { - "typeAware": true, - "typeCheck": true - }, - "env": { - "builtin": true - }, - "ignorePatterns": [ - "node_modules", - "dist", - "build", - ".content-collections", - ".tanstack-start", - ".netlify", - "public", - "convex/.temp", - ".claude" - ], - "rules": { - "no-array-constructor": "error", - "no-unused-expressions": "error", - "no-unused-vars": "error", - "typescript/ban-ts-comment": "error", - "typescript/no-duplicate-enum-values": "error", - "typescript/no-empty-object-type": "error", - "typescript/no-explicit-any": "error", - "typescript/no-extra-non-null-assertion": "error", - "typescript/no-misused-new": "error", - "typescript/no-namespace": "error", - "typescript/no-non-null-asserted-optional-chain": "error", - "typescript/no-require-imports": "error", - "typescript/no-this-alias": "error", - "typescript/no-unnecessary-type-constraint": "error", - "typescript/no-unsafe-declaration-merging": "error", - "typescript/no-unsafe-function-type": "error", - "typescript/no-wrapper-object-types": "error", - "typescript/prefer-as-const": "error", - "typescript/prefer-namespace-keyword": "error", - "typescript/triple-slash-reference": "error" - }, - "overrides": [ - { - "files": ["**/*.{js,jsx}"], - "rules": { - "constructor-super": "error", - "for-direction": "error", - "no-async-promise-executor": "error", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-compare-neg-zero": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-binary-expression": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-dupe-class-members": "error", - "no-dupe-else-if": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-pattern": "error", - "no-empty-static-block": "error", - "no-ex-assign": "error", - "no-extra-boolean-cast": "error", - "no-fallthrough": "error", - "no-func-assign": "error", - "no-global-assign": "error", - "no-import-assign": "error", - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-loss-of-precision": "error", - "no-misleading-character-class": "error", - "no-new-native-nonconstructor": "error", - "no-nonoctal-decimal-escape": "error", - "no-obj-calls": "error", - "no-prototype-builtins": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-self-assign": "error", - "no-setter-return": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", - "no-unexpected-multiline": "error", - "no-unsafe-finally": "error", - "no-unsafe-negation": "error", - "no-unsafe-optional-chaining": "error", - "no-unused-labels": "error", - "no-unused-private-class-members": "error", - "no-useless-backreference": "error", - "no-useless-catch": "error", - "no-useless-escape": "error", - "no-with": "error", - "require-yield": "error", - "use-isnan": "error", - "valid-typeof": "error" - } - }, - { - "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], - "rules": { - "constructor-super": "off", - "no-class-assign": "off", - "no-const-assign": "off", - "no-dupe-class-members": "off", - "no-dupe-keys": "off", - "no-func-assign": "off", - "no-import-assign": "off", - "no-new-native-nonconstructor": "off", - "no-obj-calls": "off", - "no-redeclare": "off", - "no-setter-return": "off", - "no-this-before-super": "off", - "no-unsafe-negation": "off", - "no-var": "error", - "no-with": "off", - "prefer-const": "error", - "prefer-rest-params": "error", - "prefer-spread": "error" - } - }, - { - "files": ["**/*.{ts,tsx}"], - "rules": { - "no-redeclare": "error", - "no-shadow": "off", - "no-unused-vars": [ - "warn", - { - "argsIgnorePattern": "(^_)|(^__+$)|(^e$)|(^error$)", - "varsIgnorePattern": "(^_)|(^__+$)|(^e$)|(^error$)", - "caughtErrorsIgnorePattern": "(^_)|(^__+$)|(^e$)|(^error$)" - } - ], - "typescript/no-explicit-any": "off" - } - }, - { - "files": ["**/*.{ts,tsx,js,jsx}"], - "rules": { - "jsx-a11y/alt-text": "error", - "jsx-a11y/anchor-ambiguous-text": "off", - "jsx-a11y/anchor-has-content": "error", - "jsx-a11y/anchor-is-valid": "error", - "jsx-a11y/aria-activedescendant-has-tabindex": "error", - "jsx-a11y/aria-props": "error", - "jsx-a11y/aria-proptypes": "error", - "jsx-a11y/aria-role": "error", - "jsx-a11y/aria-unsupported-elements": "error", - "jsx-a11y/autocomplete-valid": "error", - "jsx-a11y/click-events-have-key-events": "error", - "jsx-a11y/heading-has-content": "error", - "jsx-a11y/html-has-lang": "error", - "jsx-a11y/iframe-has-title": "error", - "jsx-a11y/img-redundant-alt": "error", - "jsx-a11y/label-has-associated-control": "error", - "jsx-a11y/media-has-caption": "error", - "jsx-a11y/mouse-events-have-key-events": "error", - "jsx-a11y/no-access-key": "error", - "jsx-a11y/no-autofocus": "error", - "jsx-a11y/no-distracting-elements": "error", - "jsx-a11y/no-noninteractive-tabindex": [ - "error", - { - "tags": [], - "roles": ["tabpanel"], - "allowExpressionValues": true - } - ], - "jsx-a11y/no-redundant-roles": "error", - "jsx-a11y/no-static-element-interactions": [ - "error", - { - "allowExpressionValues": true, - "handlers": [ - "onClick", - "onMouseDown", - "onMouseUp", - "onKeyPress", - "onKeyDown", - "onKeyUp" - ] - } - ], - "jsx-a11y/role-has-required-aria-props": "error", - "jsx-a11y/role-supports-aria-props": "error", - "jsx-a11y/scope": "error", - "jsx-a11y/tabindex-no-positive": "error", - "react/rules-of-hooks": "error", - "react/exhaustive-deps": "warn" - }, - "plugins": ["react", "jsx-a11y"] - } - ] -} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..fd1b50a53 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +**/api +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..fd496a820 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "semi": false +} diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 49055565e..000000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -.agents/index.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 49055565e..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -.agents/index.md \ No newline at end of file diff --git a/README.md b/README.md index f168f491e..eb580a5bf 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,15 @@ -
+# Welcome to TanStack.com! -# TanStack.com +This site is built with TanStack Router! -The home of the TanStack ecosystem. Built with [TanStack Router](https://tanstack.com/router) and deployed on [Cloudflare Workers](https://workers.cloudflare.com/). +- [TanStack Router Docs](https://tanstack.com/router) -Follow @TanStack +It's deployed automagically with Vercel! -### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/) - -
+- [Vercel](https://vercel.com/) ## Development -### Quick Start - From your terminal: ```sh @@ -23,64 +19,54 @@ pnpm dev This starts your app in development mode, rebuilding assets on file changes. -### Local Setup +## Editing and previewing the docs of TanStack projects locally + +The documentations for all TanStack projects except for `React Charts` are hosted on [https://tanstack.com](https://tanstack.com), powered by this TanStack Router app. +In production, the markdown doc pages are fetched from the GitHub repos of the projects, but in development they are read from the local file system. -The documentation for all TanStack projects (except `React Charts`) is hosted on [tanstack.com](https://tanstack.com). In production, doc pages are fetched from GitHub. In development, they're read from your local file system. +Follow these steps if you want to edit the doc pages of a project (in these steps we'll assume it's [`TanStack/form`](https://github.com/tanstack/form)) and preview them locally : -Create a `tanstack` parent directory and clone this repo alongside the projects: +1. Create a new directory called `tanstack`. ```sh -mkdir tanstack && cd tanstack -git clone git@github.com:TanStack/tanstack.com.git -git clone git@github.com:TanStack/query.git -git clone git@github.com:TanStack/router.git -git clone git@github.com:TanStack/table.git +mkdir tanstack ``` -Your directory structure should look like this: +2. Enter the directory and clone this repo and the repo of the project there. +```sh +cd tanstack +git clone git@github.com:TanStack/tanstack.com.git +git clone git@github.com:TanStack/form.git ``` -tanstack/ - ├── tanstack.com/ - ├── query/ - ├── router/ - └── table/ -``` - -> [!WARNING] -> Directory names must match repo names exactly (e.g., `query` not `tanstack-query`). The app finds docs by looking for sibling directories by name. - -### Editing Docs - -To edit docs for a project, make changes in its `docs/` folder (e.g., `../form/docs/`) and visit http://localhost:3000/form/latest/docs/overview to preview. > [!NOTE] -> Updated pages need to be manually reloaded in the browser. +> Your `tanstack` directory should look like this: +> +> ``` +> tanstack/ +> | +> +-- form/ +> | +> +-- tanstack.com/ +> ``` > [!WARNING] -> Update the project's `docs/config.json` if you add a new doc page! +> Make sure the name of the directory in your local file system matches the name of the project's repo. For example, `tanstack/form` must be cloned into `form` (this is the default) instead of `some-other-name`, because that way, the doc pages won't be found. -## Get Involved +3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode: -- We welcome issues and pull requests! -- Participate in [GitHub Discussions](https://github.com/TanStack/tanstack.com/discussions) -- Chat with the community on [Discord](https://discord.com/invite/WrRKjPJ) - -## Explore the TanStack Ecosystem +```sh +cd tanstack.com +pnpm i +# The app will run on https://localhost:3000 by default +pnpm dev +``` -- TanStack Config – Tooling for JS/TS packages -- TanStack DB – Reactive sync client store -- TanStack DevTools – Unified devtools panel -- TanStack Form – Type‑safe form state -- TanStack Pacer – Debouncing, throttling, batching -- TanStack Query – Async state & caching -- TanStack Ranger – Range & slider primitives -- TanStack Router – Type‑safe routing, caching & URL state -- TanStack Start – Full‑stack SSR & streaming -- TanStack Store – Reactive data store -- TanStack Table – Headless datagrids -- TanStack Virtual – Virtualized rendering +4. Now you can visit http://localhost:3000/form/latest/docs/overview in the browser and see the changes you make in `tanstack/form/docs`. -… and more at TanStack.com » +> [!NOTE] +> The updated pages need to be manually reloaded in the browser. - +> [!WARNING] +> You will need to update the `docs/config.json` file (in the project's repo) if you add a new doc page! diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 000000000..4ae3ac67a --- /dev/null +++ b/app.config.ts @@ -0,0 +1,22 @@ +import { sentryVitePlugin } from '@sentry/vite-plugin' +import { defineConfig } from '@tanstack/start/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + vite: { + plugins: () => [tsConfigPaths()], + }, + routers: { + client: { + vite: { + plugins: () => [ + sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'tanstack', + project: 'tanstack-com', + }), + ], + }, + }, + }, +}) diff --git a/app/auth/auth.ts b/app/auth/auth.ts new file mode 100644 index 000000000..7cf126840 --- /dev/null +++ b/app/auth/auth.ts @@ -0,0 +1,76 @@ +import { createCookie } from '@remix-run/node' +import { redirect } from '@tanstack/react-router' + +let secret = process.env.COOKIE_SECRET || 'default' +if (secret === 'default') { + console.warn( + '🚨 No COOKIE_SECRET environment variable set, using default. The app is insecure in production.' + ) + secret = 'default-secret' +} + +let cookie = createCookie('auth', { + secrets: [secret], + // 30 days + maxAge: 30 * 24 * 60 * 60, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', +}) + +export async function getAuthFromRequest( + request: Request +): Promise { + const c = request.headers.get('Cookie') + let userId = await cookie.parse(c) + return userId ?? null +} + +export async function setAuthOnResponse( + response: Response, + userId: string +): Promise { + let header = await cookie.serialize(userId) + response.headers.append('Set-Cookie', header) + return response +} + +export async function requireAuthCookie(request: Request) { + let userId = await getAuthFromRequest(request) + if (!userId) { + throw redirect({ + to: '/login', + headers: { + 'Set-Cookie': await cookie.serialize('', { + maxAge: 0, + }), + }, + }) + } + return userId +} + +export async function redirectIfLoggedInLoader({ + request, +}: { + request: Request +}) { + let userId = await getAuthFromRequest(request) + if (userId) { + throw redirect({ + to: '/', + }) + } + return null +} + +export async function redirectWithClearedCookie() { + return redirect({ + to: '/', + headers: { + 'Set-Cookie': await cookie.serialize(null, { + expires: new Date(0), + }), + }, + }) +} diff --git a/src/blog/ag-grid-partnership.md b/app/blog/ag-grid-partnership.md similarity index 86% rename from src/blog/ag-grid-partnership.md rename to app/blog/ag-grid-partnership.md index 8dcf3e1d6..0e06bc7f8 100644 --- a/src/blog/ag-grid-partnership.md +++ b/app/blog/ag-grid-partnership.md @@ -1,11 +1,6 @@ --- title: TanStack Table + Ag-Grid Partnership -published: 2022-06-17 -excerpt: AG Grid is now the official TanStack Table open-source partner. Together we'll educate the ecosystem about the differences between the two libraries and when to choose which. -library: table -authors: - - Tanner Linsley - - Niall Crosby +published: 6/17/2022 --- We're excited to announce that [AG Grid](https://ag-grid.com/react-data-grid/?utm_source=reacttable&utm_campaign=githubreacttable) is now the official **TanStack Table** open-source partner! Together we will strive to achieve the following goals: diff --git a/src/blog/announcing-tanstack-query-v4.md b/app/blog/announcing-tanstack-query-v4.md similarity index 92% rename from src/blog/announcing-tanstack-query-v4.md rename to app/blog/announcing-tanstack-query-v4.md index 2094f1ad3..ffc8b18fd 100644 --- a/src/blog/announcing-tanstack-query-v4.md +++ b/app/blog/announcing-tanstack-query-v4.md @@ -1,10 +1,6 @@ --- title: Announcing TanStack Query v4 -published: 2022-07-14 -excerpt: The next version of TanStack Query is here. The rebranding and monorepo restructuring finally allows us to bring the joy of react-query to other frameworks like Vue, Svelte, and Solid. -library: query -authors: - - Dominik Dorfmeister +published: 7/14/2022 --- We're excited to announce the next version of [TanStack Query](/query/v4), previously known as `react-query` 🎉. diff --git a/src/blog/announcing-tanstack-query-v5.md b/app/blog/announcing-tanstack-query-v5.md similarity index 93% rename from src/blog/announcing-tanstack-query-v5.md rename to app/blog/announcing-tanstack-query-v5.md index b061bdea3..7f006138b 100644 --- a/src/blog/announcing-tanstack-query-v5.md +++ b/app/blog/announcing-tanstack-query-v5.md @@ -1,10 +1,6 @@ --- title: Announcing TanStack Query v5 -published: 2023-10-17 -excerpt: After 91 alpha releases, 35 betas, and 16 release candidates, TanStack Query v5.0.0 is finally here — smaller, better, and more intuitive than ever. -library: query -authors: - - Dominik Dorfmeister +published: 10/17/2023 --- About one year ago, we announced the [TanStack Query v5 roadmap](https://github.com/TanStack/query/discussions/4252), and the whole team has been working hard on that version ever since. So we're super happy to announce that today is the day: After 91 alpha releases, 35 betas and 16 release candidates, TanStack Query [v5.0.0](https://github.com/TanStack/query/releases/tag/v5.0.0) is finally here! 🎉 @@ -15,7 +11,7 @@ v5 continues the journey of v4, trying to make TanStack Query smaller (v5 is ~20 As a big breaking change, we've removed most overloads from the codebase, unifying how you use `useQuery` and other hooks. This is something we wanted to do for v4, but a [TypeScript limitation](https://github.com/microsoft/TypeScript/issues/43371) prevented us from doing that. TypeScript addressed this issue in TS 4.7, so we were able to remove all the overloads that we had for calling `useQuery` with a different amount of parameters. This is a huge DX win, because methods with overloads usually have quite bad TypeScript error messages. -This is the biggest breaking change in v5, but we think it's worth it. The API is now much more consistent - you always just pass _one_ object. To alleviate the pain of changing all occurrences manually, we have tried to prepare everyone for this coming change for the last months. The documentation was changed to use the new API, and we released an auto-fixable [eslint rule](/query/v4/docs/eslint/prefer-query-object-syntax) in our eslint package. Additionally, v5 comes with [a codemod](/query/v5/docs/react/guides/migrating-to-v5#codemod) to help with the transition. +This is the biggest breaking change in v5, but we think it's worth it. The API is now much more consistent - you always just pass _one_ object. To alleviate the pain of changing all occurrences manually, we have tried to prepare everyone for this coming change for the last months. The documentation was changed to use the new API, and we released an auto-fixable [eslint rule](/query/v4/docs/react/eslint/prefer-query-object-syntax) in our eslint package. Additionally, v5 comes with [a codemod](/query/v5/docs/react/guides/migrating-to-v5#codemod) to help with the transition. Apart from that, we've renamed `cacheTime` to `gcTime` to better reflect what it is doing, merged `keepPreviousData` with `placeholderData`, renamed `loading` states to `pending` and [removed the callbacks](https://github.com/TanStack/query/discussions/5279) from `useQuery`. All these changes make v5 the most consistent and best version for new starters. diff --git a/app/client.tsx b/app/client.tsx new file mode 100644 index 000000000..9b47768ba --- /dev/null +++ b/app/client.tsx @@ -0,0 +1,8 @@ +import { hydrateRoot } from 'react-dom/client' +import { StartClient } from '@tanstack/start' +import { createRouter } from './router' +import './utils/sentry' + +const router = createRouter() + +hydrateRoot(document.getElementById('root')!, ) diff --git a/app/components/BytesForm.tsx b/app/components/BytesForm.tsx new file mode 100644 index 000000000..0df9179b1 --- /dev/null +++ b/app/components/BytesForm.tsx @@ -0,0 +1,43 @@ +import useBytesSubmit from '~/components/useBytesSubmit' +import bytesImage from '~/images/bytes.svg' + +export default function BytesForm() { + const { state, handleSubmit, error } = useBytesSubmit() + if (state === 'submitted') { + return ( +

Success! Please, check your email to confirm your subscription.

+ ) + } + return ( +
+
+
+
+ Bytes +
+ +
+ +
+

+ No spam. Unsubscribe at any time. +

+ {error &&

{error}

} +
+ ) +} diff --git a/app/components/Carbon.tsx b/app/components/Carbon.tsx new file mode 100644 index 000000000..236b97a3b --- /dev/null +++ b/app/components/Carbon.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' + +export function Carbon() { + const ref = React.useRef(null!) + + React.useEffect(() => { + ref.current.innerHTML = '' + const s = document.createElement('script') + s.id = '_carbonads_js' + s.src = `//cdn.carbonads.com/carbon.js?serve=CE7DEKQI&placement=react-tannerlinsleycom` + ref.current.appendChild(s) + }, []) + + return
+} diff --git a/app/components/ClientOnlySearchButton.tsx b/app/components/ClientOnlySearchButton.tsx new file mode 100644 index 000000000..0037a8366 --- /dev/null +++ b/app/components/ClientOnlySearchButton.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' +import { Suspense } from 'react' +import { ImSpinner2 } from 'react-icons/im' + +const LazySearchButton = React.lazy(() => + import('@orama/searchbox').then((mod) => ({ default: mod.SearchButton })) +) + +let defaultMounted = false + +export function ClientOnlySearchButton(props: any) { + const [mounted, setMounted] = React.useState(defaultMounted) + + React.useEffect(() => { + defaultMounted = true + setMounted(defaultMounted) + }, []) + + return ( + + {mounted ? ( + + ) : ( +
+ +
+ )} +
+ ) +} diff --git a/src/components/DefaultCatchBoundary.tsx b/app/components/DefaultCatchBoundary.tsx similarity index 70% rename from src/components/DefaultCatchBoundary.tsx rename to app/components/DefaultCatchBoundary.tsx index 5083603cd..1c5747f2f 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/app/components/DefaultCatchBoundary.tsx @@ -6,11 +6,6 @@ import { useMatch, useRouter, } from '@tanstack/react-router' -import * as Sentry from '@sentry/tanstackstart-react' - -import { Button } from '~/ui' -import { reloadOnStaleAppError } from '~/utils/stale-app-reload' -import { useEffect } from 'react' // type DefaultCatchBoundaryType = { // status: number @@ -21,12 +16,6 @@ import { useEffect } from 'react' export function DefaultCatchBoundary({ error }: ErrorComponentProps) { const router = useRouter() - - useEffect(() => { - if (reloadOnStaleAppError(error)) return - Sentry.captureException(error) - }, [error]) - const isRoot = useMatch({ strict: false, select: (state) => state.id === rootRouteId, @@ -44,28 +33,32 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
- + {isRoot ? ( - + ) : ( - + )}
diff --git a/app/components/Doc.tsx b/app/components/Doc.tsx new file mode 100644 index 000000000..138a10e28 --- /dev/null +++ b/app/components/Doc.tsx @@ -0,0 +1,40 @@ +import { FaEdit } from 'react-icons/fa' +import { DocTitle } from '~/components/DocTitle' +import { Markdown } from '~/components/Markdown' + +export function Doc({ + title, + content, + repo, + branch, + filePath, +}: { + title: string + content: string + repo: string + branch: string + filePath: string +}) { + return ( +
+ {title ? {title} : null} +
+
+
+
+ +
+
+
+ +
+
+ ) +} diff --git a/src/components/DocTitle.tsx b/app/components/DocTitle.tsx similarity index 100% rename from src/components/DocTitle.tsx rename to app/components/DocTitle.tsx diff --git a/app/components/DocsCalloutBytes.tsx b/app/components/DocsCalloutBytes.tsx new file mode 100644 index 000000000..32548dd99 --- /dev/null +++ b/app/components/DocsCalloutBytes.tsx @@ -0,0 +1,18 @@ +import BytesForm from '~/components/BytesForm' + +export function DocsCalloutBytes(props: React.HTMLProps) { + return ( +
+
+
+ Subscribe to Bytes +
+

+ Your weekly dose of JavaScript news. Delivered every Monday to over + 100,000 devs, for free. +

+
+ +
+ ) +} diff --git a/app/components/DocsCalloutQueryGG.tsx b/app/components/DocsCalloutQueryGG.tsx new file mode 100644 index 000000000..b9d1b58f5 --- /dev/null +++ b/app/components/DocsCalloutQueryGG.tsx @@ -0,0 +1,28 @@ +import { LogoQueryGGSmall } from '~/components/LogoQueryGGSmall' + +export function DocsCalloutQueryGG(props: React.HTMLProps) { + return ( +
+
+
+ Want to Skip the Docs? +
+ + +
+ “This course is the best way to learn how to use React Query in + real-world applications.” + —Tanner Linsley +
+ + Get the course + +
+
+ ) +} diff --git a/app/components/DocsLayout.tsx b/app/components/DocsLayout.tsx new file mode 100644 index 000000000..6a4868d45 --- /dev/null +++ b/app/components/DocsLayout.tsx @@ -0,0 +1,635 @@ +import * as React from 'react' +import { CgClose, CgMenuLeft } from 'react-icons/cg' +import { + FaArrowLeft, + FaArrowRight, + FaDiscord, + FaGithub, + FaTimes, +} from 'react-icons/fa' +import { + Link, + useMatches, + useNavigate, + useParams, + useRouterState, +} from '@tanstack/react-router' +import type { AnyOrama, SearchParamsFullText, AnyDocument } from '@orama/orama' +import { SearchBox, SearchButton } from '@orama/searchbox' +import { Carbon } from '~/components/Carbon' +import { Select } from '~/components/Select' +import { useLocalStorage } from '~/utils/useLocalStorage' +import { DocsLogo } from '~/components/DocsLogo' +import { last, capitalize } from '~/utils/utils' +import type { SelectOption } from '~/components/Select' +import type { ConfigSchema, MenuItem } from '~/utils/config' +import { create } from 'zustand' +import { searchBoxParams, searchButtonParams } from '~/components/Orama' +import { Framework, getFrameworkOptions, getLibrary } from '~/libraries' +import { DocsCalloutQueryGG } from '~/components/DocsCalloutQueryGG' +import { DocsCalloutBytes } from '~/components/DocsCalloutBytes' +import { ClientOnlySearchButton } from './ClientOnlySearchButton' +import { twMerge } from 'tailwind-merge' + +// Let's use zustand to wrap the local storage logic. This way +// we'll get subscriptions for free and we can use it in other +// components if we need to. +const useLocalCurrentFramework = create<{ + currentFramework?: string + setCurrentFramework: (framework: string) => void +}>((set) => ({ + currentFramework: + typeof document !== 'undefined' + ? localStorage.getItem('framework') || undefined + : undefined, + setCurrentFramework: (framework: string) => { + localStorage.setItem('framework', framework) + set({ currentFramework: framework }) + }, +})) + +/** + * Use framework in URL path + * Otherwise use framework in localStorage if it exists for this project + * Otherwise fallback to react + */ +function useCurrentFramework(frameworks: Framework[]) { + const navigate = useNavigate() + + const { framework: paramsFramework } = useParams({ + strict: false, + experimental_returnIntersection: true, + }) + + const localCurrentFramework = useLocalCurrentFramework() + + let framework = (paramsFramework || + localCurrentFramework.currentFramework || + 'react') as Framework + + framework = frameworks.includes(framework) ? framework : 'react' + + const setFramework = React.useCallback( + (framework: string) => { + navigate({ + params: (prev: Record) => ({ + ...prev, + framework, + }), + }) + localCurrentFramework.setCurrentFramework(framework) + }, + [localCurrentFramework, navigate] + ) + + React.useEffect(() => { + // Set the framework in localStorage if it doesn't exist + if (!localCurrentFramework.currentFramework) { + localCurrentFramework.setCurrentFramework(framework) + } + + // Set the framework in localStorage if it doesn't match the URL + if ( + paramsFramework && + paramsFramework !== localCurrentFramework.currentFramework + ) { + localCurrentFramework.setCurrentFramework(paramsFramework) + } + }) + + return { + framework, + setFramework, + } +} + +// Let's use zustand to wrap the local storage logic. This way +// we'll get subscriptions for free and we can use it in other +// components if we need to. +const useLocalCurrentVersion = create<{ + currentVersion?: string + setCurrentVersion: (version: string) => void +}>((set) => ({ + currentVersion: + typeof document !== 'undefined' + ? localStorage.getItem('version') || undefined + : undefined, + setCurrentVersion: (version: string) => { + localStorage.setItem('version', version) + set({ currentVersion: version }) + }, +})) + +/** + * Use framework in URL path + * Otherwise use framework in localStorage if it exists for this project + * Otherwise fallback to react + */ +function useCurrentVersion(versions: string[]) { + const navigate = useNavigate() + + const { version: paramsVersion } = useParams({ + strict: false, + experimental_returnIntersection: true, + }) + + const localCurrentVersion = useLocalCurrentVersion() + + let version = paramsVersion || localCurrentVersion.currentVersion || 'latest' + + version = versions.includes(version) ? version : 'latest' + + const setVersion = React.useCallback( + (version: string) => { + navigate({ + params: (prev: Record) => ({ + ...prev, + version, + }), + }) + localCurrentVersion.setCurrentVersion(version) + }, + [localCurrentVersion, navigate] + ) + + React.useEffect(() => { + // Set the version in localStorage if it doesn't exist + if (!localCurrentVersion.currentVersion) { + localCurrentVersion.setCurrentVersion(version) + } + + // Set the version in localStorage if it doesn't match the URL + if (paramsVersion && paramsVersion !== localCurrentVersion.currentVersion) { + localCurrentVersion.setCurrentVersion(paramsVersion) + } + }) + + return { + version, + setVersion, + } +} + +const useMenuConfig = ({ + config, + repo, + frameworks, +}: { + config: ConfigSchema + repo: string + frameworks: Framework[] +}) => { + const currentFramework = useCurrentFramework(frameworks) + + const localMenu: MenuItem = { + label: 'Menu', + children: [ + { + label: 'Home', + to: '..', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + ], + } + + return [ + localMenu, + // Merge the two menus together based on their group labels + ...config.sections.map((section) => { + const frameworkDocs = section.frameworks?.find( + (f) => f.label === currentFramework.framework + ) + const frameworkItems = frameworkDocs?.children ?? [] + + const children = [ + ...section.children.map((d) => ({ ...d, badge: 'core' })), + ...frameworkItems.map((d) => ({ + ...d, + badge: currentFramework.framework, + })), + ] + + if (children.length === 0) { + return undefined + } + + return { + label: section.label, + children, + } + }), + ].filter(Boolean) +} + +const useFrameworkConfig = ({ frameworks }: { frameworks: Framework[] }) => { + const currentFramework = useCurrentFramework(frameworks) + + const frameworkConfig = React.useMemo(() => { + return { + label: 'Framework', + selected: frameworks.includes(currentFramework.framework) + ? currentFramework.framework + : 'react', + available: getFrameworkOptions(frameworks), + onSelect: (option: { label: string; value: string }) => { + currentFramework.setFramework(option.value) + }, + } + }, [frameworks, currentFramework]) + + return frameworkConfig +} + +const useVersionConfig = ({ versions }: { versions: string[] }) => { + const currentVersion = useCurrentVersion(versions) + + const versionConfig = React.useMemo(() => { + const available = versions.reduce( + (acc: SelectOption[], version) => { + acc.push({ + label: version, + value: version, + }) + return acc + }, + [ + { + label: 'Latest', + value: 'latest', + }, + ] + ) + + return { + label: 'Version', + selected: versions.includes(currentVersion.version) + ? currentVersion.version + : 'latest', + available, + onSelect: (option: { label: string; value: string }) => { + currentVersion.setVersion(option.value) + }, + } + }, [currentVersion, versions]) + + return versionConfig +} + +type DocsLayoutProps = { + name: string + version: string + colorFrom: string + colorTo: string + textColor: string + config: ConfigSchema + frameworks: Framework[] + versions: string[] + repo: string + children: React.ReactNode +} + +export function DocsLayout({ + name, + version, + colorFrom, + colorTo, + textColor, + config, + frameworks, + versions, + repo, + children, +}: DocsLayoutProps) { + const { libraryId } = useParams({ + strict: false, + experimental_returnIntersection: true, + }) + const frameworkConfig = useFrameworkConfig({ frameworks }) + const versionConfig = useVersionConfig({ versions }) + const menuConfig = useMenuConfig({ config, frameworks, repo }) + + const matches = useMatches() + const lastMatch = last(matches) + + const isExample = matches.some((d) => d.pathname.includes('/examples/')) + + const detailsRef = React.useRef(null!) + + const flatMenu = React.useMemo( + () => menuConfig.flatMap((d) => d?.children), + [menuConfig] + ) + + const docsMatch = matches.find((d) => d.pathname.includes('/docs')) + + const relativePathname = lastMatch.pathname.replace( + docsMatch!.pathname + '/', + '' + ) + + const index = flatMenu.findIndex((d) => d?.to === relativePathname) + const prevItem = flatMenu[index - 1] + const nextItem = flatMenu[index + 1] + + const [showBytes, setShowBytes] = useLocalStorage('showBytes', true) + + const menuItems = menuConfig.map((group, i) => { + return ( +
+
{group?.label}
+
+
+ {group?.children?.map((child, i) => { + const linkClasses = `cursor-pointer flex gap-2 items-center justify-between group px-2 py-[.1rem] rounded-lg hover:bg-gray-500 hover:bg-opacity-10` + + return ( + + {child.to.startsWith('http') ? ( + + {child.label} + + ) : ( + { + detailsRef.current.removeAttribute('open') + }} + activeOptions={{ + exact: true, + }} + className="!cursor-pointer relative" + > + {(props) => { + return ( +
+
+ {/*
*/} + {child.label} + {/*
*/} +
+ {child.badge ? ( +
+ {child.badge} +
+ ) : null} +
+ ) + }} + + )} +
+ ) + })} +
+
+ ) + }) + + const oramaSearchParams: SearchParamsFullText = { + threshold: 0, + where: { + category: { + eq: capitalize(libraryId), + }, + }, + } + + const logo = ( + + ) + + const smallMenu = ( +
+
+ +
+ + + {logo} +
+
+
+
+ +
+ + {menuItems} +
+
+
+ ) + + const largeMenu = ( +
+
+ {logo} +
+
+ +
+
+ +
+
+ {menuItems} +
+
+ +
+
+ ) + + return ( +
+
+ +
+ {smallMenu} + {largeMenu} +
+ {children} +
+
+ {prevItem ? ( + +
+ + {prevItem.label} +
+ + ) : null} +
+
+ {nextItem ? ( + +
+ + {nextItem.label} + {' '} + +
+ + ) : null} +
+
+
+
+ {libraryId === 'query' ? : } +
+ {showBytes ? ( +
+
+ {libraryId === 'query' ? ( + + ) : ( + + )} + +
+
+ ) : ( + + )} +
+ ) +} diff --git a/app/components/DocsLogo.tsx b/app/components/DocsLogo.tsx new file mode 100644 index 000000000..720220167 --- /dev/null +++ b/app/components/DocsLogo.tsx @@ -0,0 +1,27 @@ +import { Link } from '@tanstack/react-router' + +type Props = { + name: string + linkTo: string + version: string + colorFrom: string + colorTo: string +} + +export const DocsLogo = (props: Props) => { + const { name, version, colorFrom, colorTo } = props + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${colorFrom} ${colorTo}` + + return ( + <> + + TanStack + + + {name}{' '} + {version} + + + ) +} diff --git a/src/components/Footer.tsx b/app/components/Footer.tsx similarity index 55% rename from src/components/Footer.tsx rename to app/components/Footer.tsx index 0e56901e2..0949fb744 100644 --- a/src/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,45 +1,29 @@ import { Link } from '@tanstack/react-router' -import { Card } from './Card' const footerLinks = [ { label: 'Blog', to: '/blog' }, - { label: '@Tan_Stack on X.com', to: 'https://x.com/tan_stack' }, + { label: '@Tan_Stack Twitter', to: 'https://twitter.com/tan_stack' }, { - label: '@TannerLinsley on X.com', - to: 'https://x.com/tannerlinsley', + label: '@TannerLinsley Twitter', + to: 'https://twitter.com/tannerlinsley', }, { label: 'GitHub', to: 'https://github.com/tanstack' }, { - label: 'YouTube', - to: 'https://youtube.com/@tan_stack', + label: 'Youtube', + to: 'https://www.youtube.com/user/tannerlinsley', }, { label: 'Nozzle.io - Keyword Rank Tracker', to: 'https://nozzle.io', }, - { - label: 'Ethos', - to: '/ethos', - }, - { - label: 'Tenets', - to: '/tenets', - }, - { - label: 'Privacy Policy', - to: '/privacy', - }, - { - label: 'Terms of Service', - to: '/terms', - }, ] export function Footer() { return ( -
{footerLinks.map((item) => ( @@ -54,9 +38,9 @@ export function Footer() {
))}
-
+
© {new Date().getFullYear()} TanStack LLC
- +
) } diff --git a/src/components/Logo.tsx b/app/components/Logo.tsx similarity index 99% rename from src/components/Logo.tsx rename to app/components/Logo.tsx index 509aade55..ee17d86ff 100644 --- a/src/components/Logo.tsx +++ b/app/components/Logo.tsx @@ -1,8 +1,6 @@ -import { BrandContextMenu } from '~/components/BrandContextMenu' - export function Logo(props: React.HTMLProps) { return ( - +
) { - +
) } diff --git a/src/components/LogoColor.tsx b/app/components/LogoColor.tsx similarity index 99% rename from src/components/LogoColor.tsx rename to app/components/LogoColor.tsx index 48175d645..4cfb79f07 100644 --- a/src/components/LogoColor.tsx +++ b/app/components/LogoColor.tsx @@ -1,8 +1,6 @@ -import { BrandContextMenu } from '~/components/BrandContextMenu' - export function LogoColor(props: React.HTMLProps) { return ( - +
) { - +
) } diff --git a/src/components/LogoQueryGG.tsx b/app/components/LogoQueryGG.tsx similarity index 98% rename from src/components/LogoQueryGG.tsx rename to app/components/LogoQueryGG.tsx index bfc3c6070..207f4e681 100644 --- a/src/components/LogoQueryGG.tsx +++ b/app/components/LogoQueryGG.tsx @@ -213,7 +213,7 @@ export function LogoQueryGG(props: React.HTMLProps) {
diff --git a/src/components/LogoQueryGGSmall.tsx b/app/components/LogoQueryGGSmall.tsx similarity index 97% rename from src/components/LogoQueryGGSmall.tsx rename to app/components/LogoQueryGGSmall.tsx index 56d00f537..0ff66c196 100644 --- a/src/components/LogoQueryGGSmall.tsx +++ b/app/components/LogoQueryGGSmall.tsx @@ -220,7 +220,7 @@ export function LogoQueryGGSmall(props: React.HTMLProps) {
diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx new file mode 100644 index 000000000..4667311b4 --- /dev/null +++ b/app/components/Markdown.tsx @@ -0,0 +1,212 @@ +import * as React from 'react' +import { FaRegCopy } from 'react-icons/fa' +import { MarkdownLink } from '~/components/MarkdownLink' +import type { HTMLProps } from 'react' +import { getHighlighter as shikiGetHighlighter } from 'shiki/bundle-web.mjs' +import { transformerNotationDiff } from '@shikijs/transformers' +import parse, { + attributesToProps, + domToReact, + Element, + HTMLReactParserOptions, +} from 'html-react-parser' +import { marked } from 'marked' +import { gfmHeadingId } from 'marked-gfm-heading-id' + +const CustomHeading = ({ + Comp, + id, + ...props +}: HTMLProps & { + Comp: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +}) => { + if (id) { + return ( + + + + ) + } + return +} + +const makeHeading = + (type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6') => + (props: HTMLProps) => + ( + + ) + +const markdownComponents: Record = { + a: MarkdownLink, + pre: CodeBlock, + h1: makeHeading('h1'), + h2: makeHeading('h2'), + h3: makeHeading('h3'), + h4: makeHeading('h4'), + h5: makeHeading('h5'), + h6: makeHeading('h6'), + code: function Code({ className, ...rest }: HTMLProps) { + return ( + + ) + }, + iframe: (props) => ( + +
+ +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ + ) +} diff --git a/app/routes/_libraries.index.tsx b/app/routes/_libraries.index.tsx new file mode 100644 index 000000000..ae7b57eec --- /dev/null +++ b/app/routes/_libraries.index.tsx @@ -0,0 +1,536 @@ +import { + Await, + Link, + MatchRoute, + createFileRoute, + getRouteApi, +} from '@tanstack/react-router' +import { Carbon } from '~/components/Carbon' +import { twMerge } from 'tailwind-merge' +import { CgSpinner } from 'react-icons/cg' +import { Footer } from '~/components/Footer' +import SponsorPack from '~/components/SponsorPack' +import discordImage from '~/images/discord-logo-white.svg' +import agGridImage from '~/images/ag-grid.png' +import nozzleImage from '~/images/nozzle.png' +import bytesUidotdevImage from '~/images/bytes-uidotdev.png' +import { useMutation } from '~/hooks/useMutation' +import { sample } from '~/utils/utils' +import { libraries } from '~/libraries' +import { SearchBox } from '@orama/searchbox' +import { searchBoxParams } from '~/components/Orama' +import logoColor from '~/images/logo-color-600w.png' +import bytesImage from '~/images/bytes.svg' + +export const textColors = [ + `text-rose-500`, + `text-yellow-500`, + `text-teal-500`, + `text-blue-500`, +] + +export const gradients = [ + `from-rose-500 to-yellow-500`, + `from-yellow-500 to-teal-500`, + `from-teal-500 to-violet-500`, + `from-blue-500 to-pink-500`, +] + +const courses = [ + { + name: 'The Official TanStack React Query Course', + cardStyles: `border-t-4 border-red-500 hover:(border-green-500)`, + href: 'https://query.gg/?s=tanstack', + description: `Learn how to build enterprise quality apps with TanStack's React Query the easy way with our brand new course.`, + }, +] + +export const Route = createFileRoute('/_libraries/')({ + loader: () => { + return { + randomNumber: Math.random(), + } + }, + component: Index, +}) + +async function bytesSignupServerFn({ email }: { email: string }) { + 'use server' + + return fetch(`https://bytes.dev/api/bytes-optin-cors`, { + method: 'POST', + body: JSON.stringify({ + email, + influencer: 'tanstack', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) +} + +const librariesRouteApi = getRouteApi('/_libraries') + +function Index() { + const bytesSignupMutation = useMutation({ + fn: bytesSignupServerFn, + }) + + const { randomNumber } = Route.useLoaderData() + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const gradient = sample(gradients, randomNumber) + + return ( +
+
+
+ TanStack Logo +

+ + TanStack + +

+
+

+ High-quality open-source software for{' '} + + web developers. + +

+

+ Headless, type-safe, & powerful utilities for State Management, + Routing, Data Visualization, Charts, Tables, and more. +

+
+
+
+

Open Source Libraries

+
+ {libraries.map((library, i) => { + return ( + +
+ { + if (isPending) { + console.log('pending', library.name) + } + return ( +
+ {library.name} +
+ ) + }} + /> + {library.badge ? ( +
+ {library.badge} +
+ ) : null} +
+
+ {library.tagline} +
+
+ {library.description} +
+ + ) + })} +
+
+
+
+

Partners

+
+
+
+ + Enterprise Data Grid + +
+
+
+ TanStack Table and AG Grid are respectfully the{' '} + best table/datagrid libraries around. Instead + of competing, we're working together to ensure the highest + quality table/datagrid options are available for the entire + JS/TS ecosystem and every use-case imaginable for UI/UX + developers. +
+ + Learn More + +
+
+
+
+ + Bytes Logo + +
+
+
+ TanStack's priority is to make its users productive, efficient + and knowledgeable about web dev. To help us on this quest, we've + partnered with{' '} + + ui.dev + {' '} + to provide best-in-class education about + TanStack products. It doesn't stop at TanStack though, with + their sister product{' '} + + Bytes.dev + {' '} + as our official newsletter partner, you'll be able to{' '} + stay up to date with the latest and greatest in + the web dev world regardless. +
+ + Learn More + +
+
+
+
+ + SEO keyword rank tracker + +
+
+
+ Since its founding, Nozzle's SEO platform has used TanStack + libraries to build one of the{' '} + + most technically advanced search engine monitoring platforms + + , its enterprise rank tracking and keyword research tools are + setting a new bar for quality and scale. Nozzle uses the full + gamut of TanStack tools on the front-end to deliver users with + unmatched UI/UX. +
+ + Learn More + +
+
+
+
+
+
+
+
+ +
+ + This ad helps us be happy about our invested time and not burn out + and rage-quit OSS. Yay money! 😉 + +
+
+
+
+

Courses

+ +
+
+
+

OSS Sponsors

+
+
+ } + children={(sponsors) => { + return + }} + /> +
+
+
+ +
+

+ Sponsors get special perks like{' '} + + private discord channels, priority issue requests, direct support + and even course vouchers + + ! +

+
+
+
+
+
+
+ Discord Logo +
+
+

TanStack on Discord

+

+ The official TanStack community to ask questions, network and make + new friends and get lightning fast news about what's coming next + for TanStack! +

+
+ +
+
+
+
+
+ {!bytesSignupMutation.submittedAt ? ( +
{ + e.preventDefault() + const formData = new FormData(e.currentTarget) + + bytesSignupMutation.mutate({ + email: formData.get('email_address')?.toString() || '', + }) + }} + > +
+
+

Subscribe to Bytes

+
+ Bytes Logo +
+
+ +

+ The Best JavaScript Newsletter +

+
+
+ + +
+ {bytesSignupMutation.error ? ( +

+ Looks like something went wrong. Please try again. +

+ ) : ( +

+ Join over 100,000 devs +

+ )} +
+ ) : ( +

🎉 Thank you! Please confirm your email

+ )} +
+
+
+
+
+ ) +} diff --git a/app/routes/_libraries.learn.tsx b/app/routes/_libraries.learn.tsx new file mode 100644 index 000000000..2fe6214bb --- /dev/null +++ b/app/routes/_libraries.learn.tsx @@ -0,0 +1,137 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import imgTanner from '~/images/people/tannerlinsley.jpeg' +import imgKevin from '~/images/people/kevinvancott.jpeg' +import imgDominik from '~/images/people/dominikdorfmeister.jpg' +import imgCorbin from '~/images/people/corbincrutchley.jpeg' +import { seo } from '~/utils/seo' +import { shuffle } from '~/utils/utils' +import { CiTurnL1 } from 'react-icons/ci' +import { useScript } from '~/hooks/useScript' +import { FaCheckCircle } from 'react-icons/fa' +import { LogoQueryGG } from '~/components/LogoQueryGG' + +export const Route = createFileRoute('/_libraries/learn')({ + component: LoginComp, + meta: () => + seo({ + title: 'Learn | TanStack', + description: `Education and learning resources for TanStack libraries and projects`, + keywords: `learn,course,education,learning,resources,training`, + }), +}) + +function LoginComp() { + return ( +
+
+
+
+

+
+ Educational Resources +
+
+ for TanStack Libraries +
+

+

+ Whether you're just getting started or looking to level up as an + individual or team, we have resources that will help you succeed. +

+
+
+ + +
+
+
+ Created by{' '} + Dominik Dorfmeister and{' '} + ui.dev +
+
+ +
+ “This is the best way to learn how to use React Query in + real-world applications.” +
—Tanner Linsley
+
+ +
+
+ + + +
Save time by learning with a guided approach
+
+
+ + + +
+ Get hands-on experience building a real-world application +
+
+
+ + + +
Never worry about data fetching again
+
+
+
+ +
+
+ More Coming Soon! +
+
+ {/* +
+ GitHub +
+
+ {['Bug Reports', 'Feature Requests', 'Source Code'].map((d) => ( +
+ {d} +
+ ))} +
+ + +
+ Dedicated Support +
+
+ {['Consulting', 'Enterprise Support Contracts'].map((d) => ( +
+
+ {d} +
+
+ ))} +
+ */} +
+
+
+
+ ) +} diff --git a/app/routes/_libraries.query.$version.index.tsx b/app/routes/_libraries.query.$version.index.tsx new file mode 100644 index 000000000..0cc8d7db6 --- /dev/null +++ b/app/routes/_libraries.query.$version.index.tsx @@ -0,0 +1,574 @@ +import * as React from 'react' + +import { CgCornerUpLeft, CgSpinner } from 'react-icons/cg' +import { + FaBolt, + FaBook, + FaCheckCircle, + FaCogs, + FaDiscord, + FaGithub, + FaTshirt, +} from 'react-icons/fa' +import { Await, Link, getRouteApi } from '@tanstack/react-router' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import { VscPreview, VscWand } from 'react-icons/vsc' +import { TbHeartHandshake } from 'react-icons/tb' +import SponsorPack from '~/components/SponsorPack' +import { QueryGGBanner } from '~/components/QueryGGBanner' +import { queryProject } from '~/libraries/query' +import { LogoQueryGG } from '~/components/LogoQueryGG' +import { createFileRoute } from '@tanstack/react-router' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/basic', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${queryProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/query/$version/')({ + component: VersionIndex, + meta: () => + seo({ + title: queryProject.name, + description: queryProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function VersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const branch = getBranch(queryProject, version) + const [framework, setFramework] = React.useState('react') + const [isDark, setIsDark] = React.useState(true) + + React.useEffect(() => { + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${queryProject.colorFrom} ${queryProject.colorTo}` + + return ( +
+ +
+
+
+ {menu?.map((item, i) => { + const label = ( +
+ {item.label} +
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Query{' '} + + {version === 'latest' ? queryProject.latestVersion : version} + +

+
+

+ Powerful{' '} + + asynchronous state management + {' '} + for TS/JS, React, Solid, Vue, Svelte and Angular +

+

+ Toss out that granular state management, manual refetching and + endless bowls of async-spaghetti code. TanStack Query gives you + declarative, always-up-to-date auto-managed queries and mutations + that{' '} + + directly improve both your developer and user experiences + + . +

+ + Read the Docs + +

+ (or check out{' '} + + query.gg + {' '} + – the official React Query course) +

+
+
+
+ +
+

+ Declarative & Automatic +

+

+ Writing your data fetching logic by hand is over. Tell + TanStack Query where to get your data and how fresh you need + it to be and the rest is automatic. It handles{' '} + + caching, background updates and stale data out of the box + with zero-configuration + + . +

+
+
+
+
+ +
+
+

+ Simple & Familiar +

+

+ If you know how to work with promises or async/await, then you + already know how to use TanStack Query. There's{' '} + + no global state to manage, reducers, normalization systems + or heavy configurations to understand + + . Simply pass a function that resolves your data (or throws an + error) and the rest is history. +

+
+
+
+
+ +
+
+

+ Extensible +

+

+ TanStack Query is configurable down to each observer instance + of a query with knobs and options to fit every use-case. It + comes wired up with{' '} + + dedicated devtools, infinite-loading APIs, and first class + mutation tools that make updating your data a breeze + + . Don't worry though, everything is pre-configured for + success! +

+
+
+
+ +
+
+ +
+
+
+ Created by{' '} + + Dominik Dorfmeister + {' '} + and{' '} + + ui.dev + +
+
+ +
+ “This is the best way to learn how to use React Query in + real-world applications.” +
—Tanner Linsley
+
+ +
+
+ + + + Save time by learning with a guided approach +
+
+ + + + Get hands-on experience building a real-world application +
+
+ + + + Never worry about data fetching again +
+
+ + Get the course + +
+
+
+ +
+
+

+ No dependencies. All the Features. +

+

+ With zero dependencies, TanStack Query is extremely lean given + the dense feature set it provides. From weekend hobbies all the + way to enterprise e-commerce systems (Yes, I'm lookin' at you + Walmart! 😉), TanStack Query is the battle-hardened tool to help + you succeed at the speed of your creativity. +

+
+
+ {[ + 'Backend agnostic', + 'Dedicated Devtools', + 'Auto Caching', + 'Auto Refetching', + 'Window Focus Refetching', + 'Polling/Realtime Queries', + 'Parallel Queries', + 'Dependent Queries', + 'Mutations API', + 'Automatic Garbage Collection', + 'Paginated/Cursor Queries', + 'Load-More/Infinite Scroll Queries', + 'Scroll Recovery', + 'Request Cancellation', + 'Suspense Ready!', + 'Render-as-you-fetch', + 'Prefetching', + 'Variable-length Parallel Queries', + 'Offline Support', + 'SSR Support', + 'Data Selectors', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
+ +
+
+ Trusted in Production by +
+ {/* @ts-ignore */} + +
+ {(new Array(4) as string[]) + .fill('') + .reduce( + (all) => [...all, ...all], + [ + 'Google', + 'Walmart', + 'Facebook', + 'PayPal', + 'Amazon', + 'American Express', + 'Microsoft', + 'Target', + 'Ebay', + 'Autodesk', + 'CarFAX', + 'Docusign', + 'HP', + 'MLB', + 'Volvo', + 'Ocado', + 'UPC.ch', + 'EFI.com', + 'ReactBricks', + 'Nozzle.io', + 'Uber', + ] + ) + .map((d, i) => ( + + {d} + + ))} +
+ {/* @ts-ignore */} +
+
+ +
+

+ Partners +

+
+
+ + Query You? + +
+
+ We're looking for a TanStack Query OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in + TanStack Query as we are? Let's push the boundaries of Query + together! +
+ + Let's chat + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out + and rage-quit OSS. Yay money! 😉 + +
+ +
+
+

+ Less code, fewer edge cases. +

+

+ Instead of writing reducers, caching logic, timers, retry logic, + complex async/await scripting (I could keep going...), you + literally write a tiny fraction of the code you normally would. + You will be surprised at how little code you're writing or how + much code you're deleting when you use TanStack Query. Try it + out with one of the examples below! +

+
+ {( + [ + { label: 'Angular', value: 'angular' }, + { label: 'React', value: 'react' }, + { label: 'Solid', value: 'solid' }, + { label: 'Svelte', value: 'svelte' }, + { label: 'Vue', value: 'vue' }, + ] as const + ).map((item) => ( + + ))} +
+
+
+ + {[''].includes(framework) ? ( +
+
+ Looking for the @tanstack/{framework}-query{' '} + example? We could use your help to build the{' '} + @tanstack/{framework}-query adapter! Join the{' '} + + TanStack Discord Server + {' '} + and let's get to work! +
+
+ ) : ( +
+ +
+ )} + +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Read the Docs! + +
+
+
+
+
+
+ ) +} diff --git a/app/routes/_libraries.ranger.$version.index.tsx b/app/routes/_libraries.ranger.$version.index.tsx new file mode 100644 index 000000000..6502b140f --- /dev/null +++ b/app/routes/_libraries.ranger.$version.index.tsx @@ -0,0 +1,378 @@ +import * as React from 'react' +import { CgCornerUpLeft, CgSpinner, CgTimelapse } from 'react-icons/cg' +import { + FaBook, + FaCheckCircle, + FaDiscord, + FaGithub, + FaTshirt, +} from 'react-icons/fa' +import { Await, Link } from '@tanstack/react-router' +import { TbHeartHandshake, TbZoomQuestion } from 'react-icons/tb' +import { VscPreview } from 'react-icons/vsc' +import { RiLightbulbFlashLine } from 'react-icons/ri' +import { rangerProject } from '~/libraries/ranger' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import SponsorPack from '~/components/SponsorPack' +import { createFileRoute } from '@tanstack/react-router' +import { getRouteApi } from '@tanstack/react-router' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/basic', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/overview', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${rangerProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/ranger/$version/')({ + component: VersionIndex, + meta: () => + seo({ + title: rangerProject.name, + description: rangerProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function VersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const branch = getBranch(rangerProject, version) + const [framework] = React.useState('react') + const [isDark, setIsDark] = React.useState(true) + + React.useEffect(() => { + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${rangerProject.colorFrom} ${rangerProject.colorTo}` + + return ( + <> +
+
+ {menu?.map((item, i) => { + const label = ( +
+ {item.label} +
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+

+ TanStack Ranger{' '} + + {version === 'latest' ? rangerProject.latestVersion : version} + +

+

+ Modern and{' '} + + headless + {' '} + ranger ui library +

+

+ A fully typesafe ranger hooks for React. +

+ + Get Started + +
+
+
+
+ +
+
+

+ Typesafe & powerful, yet familiarly simple +

+

+ Hooks for building range and multi-range sliders in React{' '} + + 100% typesafe without compromising on DX + + . +

+
+
+
+
+ +
+
+

+ "Headless" UI library +

+

+ Headless and extensible. Ranger doesn't render or supply any + actual UI elements. It's a{' '} + + utility for building your own custom-designed UI components + + . +

+
+
+
+
+ +
+
+

Extensible

+

+ Designed with maximum inversion of control in mind, Ranger is + built to be{' '} + + easily extended and customized + {' '} + to fit your needs. +

+
+
+
+ +
+
+

+ Feature Rich and Lightweight Headless utility, which means out of + the box, it doesn't render or supply any actual UI elements. +

+

+ Behold, the obligatory feature-list: +

+
+
+ {[ + '100% Typesafe', + 'Lightweight (306 kB)', + 'Easy to maintain', + 'Extensible', + 'Not dictating UI', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
+ +
+

+ Partners +

+
+
+ + Ranger You? + +
+
+ We're looking for a TanStack Ranger OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in TanStack + Ranger as we are? Let's push the boundaries of Ranger together! +
+ + Let's chat + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out + and rage-quit OSS. Yay money! 😉 + +
+ +
+
+

+ Take it for a spin! +

+

+ Let's see it in action! +

+
+
+ +
+ +
+
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ + ) +} diff --git a/app/routes/_libraries.router.$version.index.tsx b/app/routes/_libraries.router.$version.index.tsx new file mode 100644 index 000000000..7710a8962 --- /dev/null +++ b/app/routes/_libraries.router.$version.index.tsx @@ -0,0 +1,429 @@ +import * as React from 'react' +import { CgSpinner, CgTimelapse } from 'react-icons/cg' +import { + FaBook, + FaCheckCircle, + FaDiscord, + FaGithub, + FaTshirt, +} from 'react-icons/fa' +import { + Await, + Link, + createFileRoute, + getRouteApi, +} from '@tanstack/react-router' +import { TbHeartHandshake, TbZoomQuestion } from 'react-icons/tb' +import { VscPreview } from 'react-icons/vsc' +import { RiLightbulbFlashLine } from 'react-icons/ri' +import { routerProject } from '~/libraries/router' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import SponsorPack from '~/components/SponsorPack' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/kitchen-sink-file-based', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/framework/react/overview', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${routerProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/router/$version/')({ + component: RouterVersionIndex, + meta: () => + seo({ + title: routerProject.name, + description: routerProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +function RouterVersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const branch = getBranch(routerProject, version) + const [framework] = React.useState('react') + const [isDark, setIsDark] = React.useState(true) + + React.useEffect(() => { + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${routerProject.colorFrom} ${routerProject.colorTo}` + + return ( +
+
+ {menu?.map((item, i) => { + const label = ( +
{item.label}
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Router +

+
+

+ + Modern and scalable + {' '} + routing for React applications +

+

+ A fully type-safe React router with built-in data fetching, + stale-while revalidate caching and first-class search-param APIs. +

+ + Get Started + +
+
+
+
+ +
+
+

+ Typesafe & powerful, yet familiarly simple +

+

+ TanStack Router builds on modern routing patterns made popular by + other tools, but has been re-engineered from the ground up to be{' '} + + 100% typesafe without compromising on DX + + . You can have your cake and eat it too! +

+
+
+
+
+ +
+
+

+ Built-in Data Fetching with Caching +

+

+ Hoist your data fetching and avoid waterfalls with TanStack + Router's loader API and get{' '} + + instant navigations with built-in caching and automatic + preloading + + . Need something more custom? Router's API is{' '} + + designed to work with your favorite client-side cache libraries! + {' '} + Your users will notice the difference when your pages not only + load in parallel but also stay up to date over time. +

+
+
+
+
+ +
+
+

+ Search Param APIs to make your state-manager jealous +

+

+ Instead of throwing you to the URLSearchParam wolves, TanStack + Router outfits you with state-manager-grade search param APIs. + With{' '} + + schemas, validation, full type-safety and pre/post manipulation + {' '} + you'll wonder why you're not storing everything in the URL. + Goodbye in-memory state 👋! +

+
+
+
+ +
+
+

+ Feature Rich and Lightweight +

+

+ Behold, the obligatory feature-list: +

+
+
+ {[ + '100% Typesafe', + 'Parallel Route Loaders', + '1st-class Search Param APIs', + 'Nested/Layout Routes', + 'Lightweight (12kb)', + 'Suspense + Transitions', + 'Strict Navigation', + 'Auto-completed Paths', + 'Search Param Schemas', + 'Search Param Validation', + 'Search Param Parsing + Serialization', + 'Search Param Pre/Post Processing', + 'Structural Sharing', + 'Automatic Prefetching', + 'Asynchronous Elements', + 'Pending Elements', + 'Error Boundaries', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out and + rage-quit OSS. Yay money! 😉 + +
+ +
+
+
+

+ Take it for a spin! +

+

+ Create a route, pop in a Router, and start slingin' some code! +

+ {/*
+ {( + [ + { label: 'React', value: 'react' }, + { label: 'Preact', value: 'preact' }, + { label: 'Solid', value: 'solid' }, + { label: 'Vue', value: 'vue' }, + { label: 'Svelte', value: 'svelte' }, + ] as const + ).map((item) => ( + + ))} +
*/} +
+
+ + {/* {['preact', 'vue', 'solid', 'svelte'].includes(framework) ? ( +
+
+ Looking for the @tanstack/{framework}-router{' '} + example? The @tanstack/{framework}-router adapter + is currently under development! Join the{' '} + + TanStack Discord Server + {' '} + and help us make routing in {framework} a better place! +
+
+ ) : ( */} +
+ +
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+

+ Partners +

+
+
+ + Router You? + +
+
+ We're looking for a TanStack Router OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in TanStack + Router as we are? Let's push the boundaries of Router together! +
+ + Let's chat + +
+
+
+ +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ ) +} diff --git a/app/routes/_libraries.start.$version.index.tsx b/app/routes/_libraries.start.$version.index.tsx new file mode 100644 index 000000000..91eb00c48 --- /dev/null +++ b/app/routes/_libraries.start.$version.index.tsx @@ -0,0 +1,533 @@ +import * as React from 'react' + +import { CgCornerUpLeft, CgSpinner } from 'react-icons/cg' +import { + FaBolt, + FaBook, + FaCheckCircle, + FaCogs, + FaDiscord, + FaGithub, + FaTshirt, + FaTwitter, +} from 'react-icons/fa' +import { Await, Link, getRouteApi } from '@tanstack/react-router' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import { VscPreview, VscWand } from 'react-icons/vsc' +import { TbHeartHandshake } from 'react-icons/tb' +import SponsorPack from '~/components/SponsorPack' +import { startProject } from '~/libraries/start' +import { createFileRoute } from '@tanstack/react-router' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + // { + // label: ( + //
+ // Examples + //
+ // ), + // to: './docs/react/examples/basic', + // }, + // { + // label: ( + //
+ // Docs + //
+ // ), + // to: './docs/', + // }, + // { + // label: ( + //
+ // GitHub + //
+ // ), + // to: `https://github.com/${startProject.repo}`, + // }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/start/$version/')({ + component: VersionIndex, + meta: () => + seo({ + title: startProject.name, + description: startProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function VersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const branch = getBranch(startProject, version) + const [framework, setFramework] = React.useState('react') + const [isDark, setIsDark] = React.useState(true) + + React.useEffect(() => { + if (isDark) { + // + } + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${startProject.colorFrom} ${startProject.colorTo}` + + return ( +
+
+ {menu?.map((item, i) => { + const label = ( +
{item.label}
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Start +

+
+ {/*
*/} +
+ Coming Soon! + {/* {version === 'latest' ? latestVersion : version} */} +
+ {/*
*/} +

+ Full-stack React framework{' '} + + powered by TanStack Router + {' '} +

+

+ Full-document SSR, Streaming, Server Functions, bundling and more, + powered by TanStack Router, Vinxi,{' '} + Nitro and Vite. Ready to deploy to + your favorite hosting provider. +

+
+
+
+ So when can I use it? +
+
+
+ TanStack Start is currently in development and is + not yet available for public use. We are working hard to bring you + the best possible experience and will be releasing more details + soon. In the meantime, you can follow along with the development + process by watching the commits on this very website! +
+
+ Yes, you heard that right!{' '} + + TanStack.com is already being built and deployed using TanStack + Start + + ! We are eating our own dog food and are excited to share the + results with you soon! +
+
+ +
+ {/*
+
+ +
+

+ Built on TanStack Router +

+

+ Writing your data fetching logic by hand is over. Tell TanStack + Query where to get your data and how fresh you need it to be and + the rest is automatic. It handles{' '} + + caching, background updates and stale data out of the box with + zero-configuration + + . +

+
+
+
+
+ +
+
+

+ Simple & Familiar +

+

+ If you know how to work with promises or async/await, then you + already know how to use TanStack Query. There's{' '} + + no global state to manage, reducers, normalization systems or + heavy configurations to understand + + . Simply pass a function that resolves your data (or throws an + error) and the rest is history. +

+
+
+
+
+ +
+
+

+ Extensible +

+

+ TanStack Query is configurable down to each observer instance of a + query with knobs and options to fit every use-case. It comes wired + up with{' '} + + dedicated devtools, infinite-loading APIs, and first class + mutation tools that make updating your data a breeze + + . Don't worry though, everything is pre-configured for success! +

+
+
+
*/} + + {/*
+
+

+ No dependencies. All the Features. +

+

+ With zero dependencies, TanStack Query is extremely lean given the + dense feature set it provides. From weekend hobbies all the way to + enterprise e-commerce systems (Yes, I'm lookin' at you Walmart! 😉), + TanStack Query is the battle-hardened tool to help you succeed at + the speed of your creativity. +

+
+
+ {[ + 'Backend agnostic', + 'Dedicated Devtools', + 'Auto Caching', + 'Auto Refetching', + 'Window Focus Refetching', + 'Polling/Realtime Queries', + 'Parallel Queries', + 'Dependent Queries', + 'Mutations API', + 'Automatic Garbage Collection', + 'Paginated/Cursor Queries', + 'Load-More/Infinite Scroll Queries', + 'Scroll Recovery', + 'Request Cancellation', + 'Suspense Ready!', + 'Render-as-you-fetch', + 'Prefetching', + 'Variable-length Parallel Queries', + 'Offline Support', + 'SSR Support', + 'Data Selectors', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
*/} + + {/*
+
+ Trusted in Production by +
+ +
+ {(new Array(4) as string[]) + .fill('') + .reduce( + (all) => [...all, ...all], + [ + 'Google', + 'Walmart', + 'Facebook', + 'PayPal', + 'Amazon', + 'American Express', + 'Microsoft', + 'Target', + 'Ebay', + 'Autodesk', + 'CarFAX', + 'Docusign', + 'HP', + 'MLB', + 'Volvo', + 'Ocado', + 'UPC.ch', + 'EFI.com', + 'ReactBricks', + 'Nozzle.io', + 'Uber', + ] + ) + .map((d, i) => ( + + {d} + + ))} +
+
+
*/} + +
+

+ Partners +

+
+
+ + Start You? + +
+
+ We're looking for a TanStack Start OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in TanStack + Start as we are? Let's push the boundaries of Start together! +
+ + Let's chat + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out and + rage-quit OSS. Yay money! 😉 + +
+ + {/*
+
+

+ Less code, fewer edge cases. +

+

+ Instead of writing reducers, caching logic, timers, retry logic, + complex async/await scripting (I could keep going...), you literally + write a tiny fraction of the code you normally would. You will be + surprised at how little code you're writing or how much code you're + deleting when you use TanStack Query. Try it out with one of the + examples below! +

+
+ {( + [ + { label: 'Angular', value: 'angular' }, + { label: 'React', value: 'react' }, + { label: 'Solid', value: 'solid' }, + { label: 'Svelte', value: 'svelte' }, + { label: 'Vue', value: 'vue' }, + ] as const + ).map((item) => ( + + ))} +
+
+
*/} + + {/* {[''].includes(framework) ? ( +
+
+ Looking for the @tanstack/{framework}-query{' '} + example? We could use your help to build the{' '} + @tanstack/{framework}-query adapter! Join the{' '} + + TanStack Discord Server + {' '} + and let's get to work! +
+
+ ) : ( +
+ +
+ )} */} + + {/*
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Read the Docs! + +
+
*/} +
+
+ ) +} diff --git a/app/routes/_libraries.store.$version.index.tsx b/app/routes/_libraries.store.$version.index.tsx new file mode 100644 index 000000000..407798af9 --- /dev/null +++ b/app/routes/_libraries.store.$version.index.tsx @@ -0,0 +1,427 @@ +import { CgCornerUpLeft, CgSpinner } from 'react-icons/cg' +import { FaBook, FaDiscord, FaGithub, FaTshirt } from 'react-icons/fa' +import { Link, createFileRoute, getRouteApi } from '@tanstack/react-router' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import { VscPreview } from 'react-icons/vsc' +import { TbHeartHandshake } from 'react-icons/tb' +import SponsorPack from '~/components/SponsorPack' +import { storeProject } from '~/libraries/store' +import { Await } from '@tanstack/react-router' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/simple', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${storeProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/store/$version/')({ + component: StoreVersionIndex, + meta: () => + seo({ + title: storeProject.name, + description: storeProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function StoreVersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + // const branch = getBranch(version) + // const [framework, setFramework] = React.useState('react') + // const [isDark, setIsDark] = React.useState(true) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${storeProject.colorFrom} ${storeProject.colorTo}` + + return ( + <> +
+
+ {menu?.map((item, i) => { + const label = ( +
+ {item.label} +
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Store{' '} + + {version === 'latest' ? storeProject.latestVersion : version} + +

+
+

+ + Framework agnostic + {' '} + type-safe store w/ reactive framework adapters +

+

+ Level up your state management with TanStack Store – the + framework-agnostic, type-safe store. Enjoy{' '} + + minimal setup, granular APIs, and seamless adaptability across + frameworks + + . Simplify your development and boost efficiency with TanStack + Store. +

+ + Get Started + +
+ {/*
+
+ +
+

+ First-Class TypeScript Support +

+

+ TanStack Form touts first-class TypeScript support with + outstanding autocompletion, excellent generic throughput and + inferred types everywhere possible.{' '} + + This results in fewer runtime errors, increased code + maintainability, and a smoother development experience + {' '} + to help you confidently build robust and type-safe form + solutions that scale. +

+
+
+
+
+ +
+
+

+ Headless and Framework Agnostic +

+

+ Form's headless and framework agnostic approach ensures maximum + flexibility and broad compatibility with many front-end + frameworks, or no framework at all. By both supplying and + encouraging a headless approach to your forms, building custom + reusable form components tailored to your application's needs{' '} + + requires little abstraction and keeps your code modular, + simple and composable. + +

+
+
+
+
+ +
+
+

+ Granular Reactive Performance +

+

+ When it comes to performance, TanStack Form delivers amazing + speed and control, but without the cruft, boilerplate, or + abstractions. With granularly reactive APIs at its core,{' '} + + only relevant components are updated when the form state + changes. + {' '} + The end result? A faster UI, happy users, and zero worries about + performance. +

+
+
+
*/} + + {/*
+
+

+ No dependencies. All the Features. +

+

+ With zero dependencies, TanStack Form is extremely lean given the + dense feature set it provides. From weekend hobbies all the way to + enterprise TanStack Form has the tools to help you succeed at the + speed of your creativity. +

+
+
+ {[ + // A list of features that @tanstack/form provides for managing form state, validation, touched/dirty states, UI integration, etc. + 'Framework agnostic design', + 'First Class TypeScript Support', + 'Headless', + 'Tiny / Zero Deps', + 'Granularly Reactive Components/Hooks', + 'Extensibility and plugin architecture', + 'Modular architecture', + 'Form/Field validation', + 'Async Validation', + 'Built-in Async Validation Debouncing', + 'Configurable Validation Events', + 'Deeply Nested Object/Array Fields', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
*/} + +
+

+ Partners +

+
+
+ + Store You? + +
+
+ We're looking for a TanStack Store OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in TanStack + Store as we are? Let's push the boundaries of Store together! +
+ + Let's chat + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out + and rage-quit OSS. Yay money! 😉 + +
+ + {/*
+
+

+ Less code, fewer edge cases. +

+

+ Instead of encouraging hasty abstractions and hook-focused APIs, + TanStack Form embraces composition where it counts by giving you + headless APIs via components (and hooks if you want them of + course). TanStack Form is designed to be used directly in your + components and UI. This means less code, fewer edge cases, and + deeper control over your UI. Try it out with one of the examples + below! +

+
+ {( + [ + { label: 'React', value: 'react' }, + { label: 'Solid', value: 'solid' }, + { label: 'Svelte', value: 'svelte' }, + { label: 'Vue', value: 'vue' }, + ] as const + ).map((item) => ( + + ))} +
+
+
+ + {['solid', 'vue', 'svelte'].includes(framework) ? ( +
+
+ Looking for the @tanstack/{framework}-form{' '} + example? We could use your help to build the{' '} + @tanstack/{framework}-form adapter! Join the{' '} + + TanStack Discord Server + {' '} + and let's get to work! +
+
+ ) : ( +
+ +
+ )} */} + +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ + ) +} diff --git a/app/routes/_libraries.support.tsx b/app/routes/_libraries.support.tsx new file mode 100644 index 000000000..7c6349479 --- /dev/null +++ b/app/routes/_libraries.support.tsx @@ -0,0 +1,106 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import imgTanner from '~/images/people/tannerlinsley.jpeg' +import imgKevin from '~/images/people/kevinvancott.jpeg' +import imgDominik from '~/images/people/dominikdorfmeister.jpg' +import imgCorbin from '~/images/people/corbincrutchley.jpeg' +import { seo } from '~/utils/seo' +import { shuffle } from '~/utils/utils' +import { CiTurnL1 } from 'react-icons/ci' +import { useScript } from '~/hooks/useScript' + +export const Route = createFileRoute('/_libraries/support')({ + component: LoginComp, + meta: () => + seo({ + title: 'Support | TanStack', + description: `Help and support for TanStack libraries and projects`, + keywords: `tanstack,react,reactjs,react query,react table,open source,open source software,oss,software,help,support`, + }), +}) + +function LoginComp() { + return ( +
+
+
+
+

+
+ Support +
+
+ for TanStack Libraries +
+

+

+ Whether you're a solo developer or a large enterprise, we have + solutions that will fit your needs like a glove! +

+
+
+ +
+ Discord +
+
+ {['Community Support', 'Q&A', 'General Chat', 'Networking'].map( + (d) => ( +
+ {d} +
+ ) + )} +
+ + +
+ GitHub +
+
+ {['Bug Reports', 'Feature Requests', 'Source Code'].map((d) => ( +
+ {d} +
+ ))} +
+ + +
+ Dedicated Support +
+
+ {['Consulting', 'Enterprise Support Contracts'].map((d) => ( +
+
+ {d} +
+
+ ))} +
+ +
+
+
+
+ ) +} diff --git a/app/routes/_libraries.table.$version.index.tsx b/app/routes/_libraries.table.$version.index.tsx new file mode 100644 index 000000000..538a27247 --- /dev/null +++ b/app/routes/_libraries.table.$version.index.tsx @@ -0,0 +1,488 @@ +import * as React from 'react' +import { CgCornerUpLeft, CgSpinner } from 'react-icons/cg' +import { + FaBolt, + FaBook, + FaCheckCircle, + FaCogs, + FaDiscord, + FaGithub, + FaTshirt, +} from 'react-icons/fa' +import { Link, createFileRoute, getRouteApi } from '@tanstack/react-router' +import { tableProject } from '~/libraries/table' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import { IoIosBody } from 'react-icons/io' +import SponsorPack from '~/components/SponsorPack' +import { VscPreview } from 'react-icons/vsc' +import agGridImage from '~/images/ag-grid.png' +import { Await } from '@tanstack/react-router' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' +import { getInitialSandboxFileName } from '~/utils/sandbox' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/basic', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/introduction', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${tableProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/table/$version/')({ + component: TableVersionIndex, + meta: () => + seo({ + title: tableProject.name, + description: tableProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function TableVersionIndex() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const branch = getBranch(tableProject, version) + const [framework, setFramework] = React.useState('react') + const [isDark, setIsDark] = React.useState(true) + + const sandboxFirstFileName = getInitialSandboxFileName(framework) + + React.useEffect(() => { + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${tableProject.colorFrom} ${tableProject.colorTo}` + + return ( +
+
+ {menu?.map((item, i) => { + const label = ( +
{item.label}
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Table{' '} + + v8 + +

+
+

+ + Headless + {' '} + UI for building powerful tables & datagrids +

+

+ Supercharge your tables or build a datagrid from scratch for TS/JS, + React, Vue, Solid & Svelte while retaining 100% control over markup + and styles. +

+ + Get Started + +
+
+
+
+ +
+
+

+ Designed for zero design +

+

+ What good is a powerful table if that super hip designer you just + hired can't work their UI magic on it?{' '} + + TanStack Table is headless by design + + , which means 100% control down to the very smallest HTML tag, + component, class and style. Pixel Perfection? Go for it! +

+
+
+
+
+ +
+
+

+ Big Power, Small Package +

+

+ Don't be fooled by the small bundle size. TanStack Table is a + workhorse. It's built to materialize, filter, sort, group, + aggregate, paginate and display massive data sets using a very + small API surface. Wire up your new or existing tables and{' '} + + watch your users become instantly more productive + + . +

+
+
+
+
+ +
+
+

+ Extensible +

+

+ TanStack table ships with excellent defaults to get you off the + ground as fast as possible, but nothing is stopping you from{' '} + + customizing and overriding literally everything to your liking + + . Feeling tenacious enough to build your own Sheets/Excel/AirTable + clone? Be our guest 😉 +

+
+
+
+ +
+
+

+ Framework Agnostic & Feature Rich +

+

+ TanStack Table's API and engine are highly modular and + framework-independent while still prioritizing ergonomics. Behold, + the obligatory feature-list: +

+
+
+ {[ + 'Lightweight (10 - 15kb)', + 'Tree-Shaking', + 'Headless', + 'Cell Formatters', + 'Auto-managed internal state', + 'Opt-in fully controlled state', + 'Sorting', + 'Multi Sort', + 'Global Filters', + 'Columns Filters', + 'Pagination', + 'Row Grouping', + 'Aggregation', + 'Row Selection', + 'Row Expansion', + 'Column Ordering', + 'Column Visibility', + 'Column Resizing', + 'Virtualizable', + 'Server-side/external Data', + 'Nested/Grouped Headers', + 'Footers', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
+ +
+
+ Trusted in Production by +
+ {/* @ts-ignore */} + +
+ {(new Array(4) as string[]) + .fill('') + .reduce( + (all) => [...all, ...all], + [ + 'Intuit', + 'Google', + 'Amazon', + 'Apple', + 'AutoZone', + 'Microsoft', + 'Cisco', + 'Uber', + 'Salesforce', + 'Walmart', + 'Wix', + 'HP', + 'Docusign', + 'Tripwire', + 'Yahoo!', + 'Ocado', + 'Nordstrom', + 'TicketMaster', + 'Comcast Business', + 'Nozzle.io', + ] + ) + .map((d, i) => ( + + {d} + + ))} +
+ {/* @ts-ignore */} +
+
+ +
+

+ Partners +

+
+
+ + AG Grid + +
+
+ TanStack Table and AG Grid are respectfully the{' '} + best table/datagrid libraries around. Instead of + competing, we're working together to ensure the highest quality + table/datagrid options are available for the entire JS/TS + ecosystem and every use-case. +
+ + Read More + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out and + rage-quit OSS. Yay money! 😉 + +
+ +
+
+

+ Take it for a spin! +

+

+ With some basic styles, some table markup and few columns, you're + already well on your way to creating a drop-dead powerful table. +

+
+ {( + [ + { label: 'Angular', value: 'angular' }, + { label: 'Lit', value: 'lit' }, + { label: 'Qwik', value: 'qwik' }, + { label: 'React', value: 'react' }, + { label: 'Solid', value: 'solid' }, + { label: 'Svelte', value: 'svelte' }, + { label: 'Vue', value: 'vue' }, + { label: 'Vanilla', value: 'vanilla' }, + ] as const + ).map((item) => ( + + ))} +
+
+
+ +
+ +
+ +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ ) +} diff --git a/app/routes/_libraries.tsx b/app/routes/_libraries.tsx new file mode 100644 index 000000000..2f1a682e2 --- /dev/null +++ b/app/routes/_libraries.tsx @@ -0,0 +1,286 @@ +import * as React from 'react' +import { + Link, + Outlet, + createFileRoute, + defer, + useParams, +} from '@tanstack/react-router' +import { CgClose, CgMenuLeft, CgMusicSpeaker } from 'react-icons/cg' +import { MdLibraryBooks, MdSupport } from 'react-icons/md' +import { twMerge } from 'tailwind-merge' +import { SearchBox } from '@orama/searchbox' +import { sortBy } from '~/utils/utils' +import logoColor100w from '~/images/logo-color-100w.png' +import { + FaDiscord, + FaGithub, + FaInstagram, + FaTshirt, + FaTwitter, +} from 'react-icons/fa' +import { getSponsorsForSponsorPack } from '~/server/sponsors' +import { getLibrary, libraries } from '~/libraries' +import { Scarf } from '~/components/Scarf' +import { searchBoxParams, searchButtonParams } from '~/components/Orama' +import { ClientOnlySearchButton } from '~/components/ClientOnlySearchButton' + +export const Route = createFileRoute('/_libraries')({ + loader: async (ctx) => { + return { + sponsorsPromise: defer(getSponsorsForSponsorPack()), + } + }, + component: LibrariesLayout, +}) + +function LibrariesLayout() { + const { libraryId } = useParams({ + strict: false, + experimental_returnIntersection: true, + }) + const library = libraryId ? getLibrary(libraryId) : undefined + const detailsRef = React.useRef(null!) + + const linkClasses = `flex items-center justify-between group px-2 py-1 rounded-lg hover:bg-gray-500 hover:bg-opacity-10 font-black` + + const [mounted, setMounted] = React.useState(false) + + React.useEffect(() => { + setMounted(true) + }, []) + + const items = ( + <> + {sortBy(libraries, (d) => !d.name.includes('TanStack')).map( + (library, i) => { + const [prefix, name] = library.name.split(' ') + + return ( +
+ {library.to.startsWith('http') ? ( + + + + {prefix} + {' '} + {name} + + + ) : ( + { + detailsRef.current.removeAttribute('open') + }} + > + {(props) => { + return ( +
+ + + {prefix} + {' '} + + {name} + + + {library.badge ? ( + + {library.badge} + + ) : null} +
+ ) + }} + + )} +
+ ) + } + )} +
+
+
+ {[ + { + label: 'Support', + icon: , + to: '/support', + }, + { + label: 'Learn', + icon: , + to: '/learn', + }, + { + label: 'Discord', + icon: , + to: 'https://tlinz.com/discord', + target: '_blank', + }, + { + label: 'Merch', + icon: , + to: 'https://cottonbureau.com/people/tanstack', + }, + { + label: 'Blog', + icon: , + to: '/blog', + }, + { + label: 'GitHub', + icon: , + to: 'https://github.com/tanstack', + }, + ].map((item, i) => { + return ( + +
+
+ {item.icon} +
+
{item.label}
+
+ + ) + })} + + ) + + const logo = ( +
+ + +
TanStack
+ + +
+ ) + + const smallMenu = ( +
+
+ +
+ + + {logo} +
+
+
+
+ +
+
+ {items} +
+
+
+
+ ) + + const largeMenu = ( + <> +
+
+ {logo} +
+
+ +
+
+
+ {items} +
+
+
+ + ) + + return ( +
+ {smallMenu} + {largeMenu} +
+ {library?.scarfId ? : null} + +
+ {mounted ? ( +
+ +
+ ) : null} +
+ ) +} diff --git a/app/routes/_libraries.virtual.$version.index.tsx b/app/routes/_libraries.virtual.$version.index.tsx new file mode 100644 index 000000000..242f9c303 --- /dev/null +++ b/app/routes/_libraries.virtual.$version.index.tsx @@ -0,0 +1,477 @@ +import * as React from 'react' + +import { CgCornerUpLeft, CgSpinner } from 'react-icons/cg' +import { + FaBolt, + FaBook, + FaCheckCircle, + FaCogs, + FaDiscord, + FaGithub, + FaTshirt, +} from 'react-icons/fa' +import { + Await, + Link, + createFileRoute, + getRouteApi, +} from '@tanstack/react-router' +import { virtualProject } from '~/libraries/virtual' +import { Carbon } from '~/components/Carbon' +import { Footer } from '~/components/Footer' +import { IoIosBody } from 'react-icons/io' +import SponsorPack from '~/components/SponsorPack' +import { TbHeartHandshake } from 'react-icons/tb' +import { VscPreview } from 'react-icons/vsc' +import { Logo } from '~/components/Logo' +import { getSponsorsForSponsorPack } from '~/server/sponsors' +import { Framework, getBranch } from '~/libraries' +import { seo } from '~/utils/seo' + +const menu = [ + { + label: ( +
+ TanStack +
+ ), + to: '/', + }, + { + label: ( +
+ Examples +
+ ), + to: './docs/framework/react/examples/dynamic', + }, + { + label: ( +
+ Docs +
+ ), + to: './docs/introduction', + }, + { + label: ( +
+ GitHub +
+ ), + to: `https://github.com/${virtualProject.repo}`, + }, + { + label: ( +
+ Discord +
+ ), + to: 'https://tlinz.com/discord', + }, + { + label: ( +
+ Merch +
+ ), + to: `https://cottonbureau.com/people/tanstack`, + }, +] + +export const Route = createFileRoute('/_libraries/virtual/$version/')({ + component: RouteComp, + meta: () => + seo({ + title: virtualProject.name, + description: virtualProject.description, + }), +}) + +const librariesRouteApi = getRouteApi('/_libraries') + +export default function RouteComp() { + const { sponsorsPromise } = librariesRouteApi.useLoaderData() + const { version } = Route.useParams() + const [framework, setFramework] = React.useState('react') + const branch = getBranch(virtualProject, version) + const [isDark, setIsDark] = React.useState(true) + + React.useEffect(() => { + setIsDark(window.matchMedia?.(`(prefers-color-scheme: dark)`).matches) + }, []) + + const gradientText = `inline-block text-transparent bg-clip-text bg-gradient-to-r ${virtualProject.colorFrom} ${virtualProject.colorTo}` + + return ( +
+
+ {menu?.map((item, i) => { + const label = ( +
{item.label}
+ ) + + return ( +
+ {item.to.startsWith('http') ? ( + {label} + ) : ( + + {label} + + )} +
+ ) + })} +
+
+
+

+ TanStack Virtual{' '} + + v3 + +

+
+

+ + Headless + {' '} + UI for Virtualizing Large Element Lists +

+

+ Virtualize only the visible DOM nodes within massive scrollable + elements at 60FPS in TS/JS, React, Vue, Solid & Svelte while retaining + 100% control over markup and styles. +

+ + Get Started + +
+
+
+
+ +
+
+

+ Designed for zero design +

+

+ Headless Virtualization means you're always in control of your{' '} + + markup, styles and components + + . Go design and implement the most beautiful UI you can dream up + and let us take care of the hard parts. +

+
+
+
+
+ +
+
+

+ Big Power, Small Package +

+

+ Don't be fooled by the small bundle size. TanStack Virtual uses + every byte to deliver powerful performance. After all,{' '} + + {' '} + 60FPS is table stakes + {' '} + these days and we refuse to sacrifice anything for that 🧈-y + smooth UX. +

+
+
+
+
+ +
+
+

+ Maximum Composability +

+

+ With a single function/hook, you'll get limitless virtualization + for{' '} + + vertical, horizontal, and grid-style{' '} + + layouts. The API is tiny (literally 1 function), but its + composability is not. +

+
+
+
+ +
+
+

+ Framework Agnostic & Feature Rich +

+

+ TanStack Table's API and engine are highly modular and + framework-independent while still prioritizing ergonomics. Behold, + the obligatory feature-list: +

+
+
+ {[ + 'Lightweight (10 - 15kb)', + 'Tree-Shaking', + 'Headless', + 'Vertical/Column Virtualization', + 'Horizontal/Row Virtualization', + 'Grid Virtualization', + 'Window-Scrolling', + 'Fixed Sizing', + 'Variable Sizing', + 'Dynamic/Measured Sizing', + 'Scrolling Utilities', + 'Sticky Items', + ].map((d, i) => { + return ( + + {d} + + ) + })} +
+
+ + {/*
+
+ Trusted in Production by +
+ +
+ {(new Array(4) as string[]) + .fill('') + .reduce( + (all) => [...all, ...all], + [ + 'Intuit', + 'Google', + 'Amazon', + 'Apple', + 'AutoZone', + 'Microsoft', + 'Cisco', + 'Uber', + 'Salesforce', + 'Walmart', + 'Wix', + 'HP', + 'Docusign', + 'Tripwire', + 'Yahoo!', + 'Ocado', + 'Nordstrom', + 'TicketMaster', + 'Comcast Business', + 'Nozzle.io', + ] + ) + .map((d, i) => ( + + {d} + + ))} +
+
+
*/} + +
+

+ Partners +

+
+
+ + Virtual You? + +
+
+ We're looking for a TanStack Virtual OSS Partner to go above and + beyond the call of sponsorship. Are you as invested in TanStack + Virtual as we are? Let's push the boundaries of Virtual together! +
+ + Let's chat + +
+
+
+ +
+

+ Sponsors +

+
+ } + children={(sponsors) => { + return + }} + /> +
+ +
+ +
+
+ +
+ + This ad helps us be happy about our invested time and not burn out and + rage-quit OSS. Yay money! 😉 + +
+ +
+
+

+ Take it for a spin! +

+

+ With just a few divs and some inline styles, you're already well on + your way to creating an extremely powerful virtualization + experience. +

+
+ {( + [ + { label: 'React', value: 'react' }, + { label: 'Solid', value: 'solid' }, + { label: 'Svelte', value: 'svelte' }, + { label: 'Vue', value: 'vue' }, + ] as const + ).map((item) => ( + + ))} +
+
+
+ + {['vue', 'solid', 'svelte'].includes(framework) ? ( +
+
+ Looking for the @tanstack/{framework}-virtual{' '} + example? We could use your help to build the{' '} + @tanstack/{framework}-virtual adapter! Join the{' '} + + TanStack Discord Server + {' '} + and let's get to work! +
+
+ ) : ( +
+ +
+ )} + +
+
+ Wow, you've come a long way! +
+
+ Only one thing left to do... +
+
+ + Get Started! + +
+
+
+
+ ) +} diff --git a/app/routes/blog.$.tsx b/app/routes/blog.$.tsx new file mode 100644 index 000000000..48148d8f2 --- /dev/null +++ b/app/routes/blog.$.tsx @@ -0,0 +1,62 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' +import { extractFrontMatter, fetchRepoFile } from '~/utils/documents.server' +import removeMarkdown from 'remove-markdown' +import { seo } from '~/utils/seo' +import { Doc } from '~/components/Doc' +import { PostNotFound } from './blog' +import { createServerFn } from '@tanstack/start' + +const fetchBlogPost = createServerFn( + 'GET', + async ({ docsPath }: { docsPath: string }, req) => { + 'use server' + + if (!docsPath) { + throw new Error('Invalid docs path') + } + + const filePath = `app/blog/${docsPath}.md` + + const file = await fetchRepoFile('tanstack/tanstack.com', 'main', filePath) + + if (!file) { + throw notFound() + } + + const frontMatter = extractFrontMatter(file) + const description = removeMarkdown(frontMatter.excerpt ?? '') + + return { + title: frontMatter.data.title, + description, + published: frontMatter.data.published, + content: frontMatter.content, + filePath, + } + } +) + +export const Route = createFileRoute('/blog/$')({ + loader: ({ params }) => fetchBlogPost({ docsPath: params._splat }), + meta: ({ loaderData }) => + seo({ + title: `${loaderData?.title ?? 'Docs'} | TanStack Blog`, + description: loaderData?.description, + }), + notFoundComponent: () => , + component: BlogPost, +}) + +export default function BlogPost() { + const { title, content, filePath } = Route.useLoaderData() + + return ( + + ) +} diff --git a/app/routes/blog.index.tsx b/app/routes/blog.index.tsx new file mode 100644 index 000000000..e6d4a1a71 --- /dev/null +++ b/app/routes/blog.index.tsx @@ -0,0 +1,119 @@ +import { Link, createFileRoute, notFound } from '@tanstack/react-router' + +import { getPostList } from '~/utils/blog' +import { DocTitle } from '~/components/DocTitle' +import { Markdown } from '~/components/Markdown' +import { format } from 'date-fns' +import { Footer } from '~/components/Footer' +import { extractFrontMatter, fetchRepoFile } from '~/utils/documents.server' +import { PostNotFound } from './blog' +import { createServerFn } from '@tanstack/start' + +const fetchFrontMatters = createServerFn('GET', async () => { + 'use server' + + const postInfos = getPostList() + + const frontMatters = await Promise.all( + postInfos.map(async (info) => { + const filePath = `app/blog/${info.id}.md` + + const file = await fetchRepoFile( + 'tanstack/tanstack.com', + 'main', + filePath + ) + + if (!file) { + throw notFound() + } + + const frontMatter = extractFrontMatter(file) + + return [ + info.id, + { + title: frontMatter.data.title, + published: frontMatter.data.published, + excerpt: frontMatter.excerpt, + }, + ] as const + }) + ) + + return frontMatters + + // return json(frontMatters, { + // headers: { + // 'Cache-Control': 'public, max-age=300, s-maxage=3600', + // }, + // }) +}) + +export const Route = createFileRoute('/blog/')({ + loader: () => fetchFrontMatters(), + notFoundComponent: () => , + component: BlogIndex, + meta: () => [ + { + title: 'Blog', + }, + ], +}) + +function BlogIndex() { + const frontMatters = Route.useLoaderData() + + return ( +
+
+
+ Latest Posts +
+
+
+
+
+ {frontMatters.map(([id, { title, published, excerpt }]) => { + return ( + +
+
{title}
+ {published ? ( +
+ {format(new Date(published), 'MMM dd, yyyy')} +
+ ) : null} +
+ , + }} + code={excerpt || ''} + /> +
+
+
+
+ Read More +
+
+ + ) + })} +
+
+
+
+
+ ) +} diff --git a/app/routes/blog.tsx b/app/routes/blog.tsx new file mode 100644 index 000000000..bdad8ee60 --- /dev/null +++ b/app/routes/blog.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' +import { Link, Outlet, createFileRoute } from '@tanstack/react-router' +import { Carbon } from '~/components/Carbon' +import { seo } from '~/utils/seo' +import { CgClose, CgMenuLeft } from 'react-icons/cg' + +const logo = ( + <> + + TanStack + + + + Blog + + + +) + +const localMenu = [ + { + label: 'Blog', + children: [ + { + label: 'Latest Posts', + to: '.', + }, + ], + }, +] as const + +export const Route = createFileRoute('/blog')({ + meta: () => + seo({ + title: 'Blog | TanStack', + description: 'The latest news and blog posts from TanStack!', + }), + component: Blog, +}) + +export function PostNotFound() { + return ( +
+

+
404
+
Not Found
+

+
Post not found.
+ + Blog Home + +
+ ) +} + +function Blog() { + const detailsRef = React.useRef(null!) + + const menuItems = localMenu.map((group) => { + return ( +
+
{group.label}
+
+
+ {group.children?.map((child, i) => { + return ( +
+ {child.to.startsWith('http') ? ( + {child.label} + ) : ( + { + detailsRef.current.removeAttribute('open') + }} + activeOptions={{ + exact: true, + }} + > + {child.label} + + )} +
+ ) + })} +
+
+ ) + }) + + const smallMenu = ( +
+
+ +
+ + + {logo} +
+
+
+ {menuItems} +
+
+
+ ) + + const largeMenu = ( +
+
{logo}
+
+
+ {menuItems} +
+
+ +
+
+ ) + + return ( +
+ {smallMenu} + {largeMenu} +
+ +
+
+ ) +} diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx new file mode 100644 index 000000000..67c1a5a6c --- /dev/null +++ b/app/routes/dashboard.tsx @@ -0,0 +1,59 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/start' +import { redirectWithClearedCookie, requireAuthCookie } from '~/auth/auth' +import { useMutation } from '~/hooks/useMutation' + +const loadDashboard = createServerFn('GET', async (_, { request }) => { + 'use server' + + const userId = await requireAuthCookie(request) + + return { + userId, + } +}) + +const logoutFn = createServerFn('POST', async () => { + 'use server' + + return redirectWithClearedCookie() +}) + +export const Route = createFileRoute('/dashboard')({ + loader: () => loadDashboard(), + component: LoginComp, +}) + +function LoginComp() { + const { userId } = Route.useLoaderData() + + const mutation = useMutation({ + fn: logoutFn, + }) + + return ( +
+

Dashboard

+
+ Welcome! Your userId is "{userId}" This is an experiment to test: +
+
    +
  • + Our ability to access original document request headers and cookies in + server functions +
  • +
  • Our ability to clear cookies in server functions
  • +
  • Our ability to redirect from server functions
  • +
  • Our ability to use a custom hook to manage mutations
  • +
+
+ +
+
+ ) +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 000000000..739e394af --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,101 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn, json } from '@tanstack/start' +import { setAuthOnResponse } from '~/auth/auth' +import { useMutation } from '~/hooks/useMutation' + +const loginFn = createServerFn( + 'POST', + async ( + formData: TypedFormData<{ + username: string + password: string + }> + ) => { + 'use server' + + const username = formData.get('username') as string + const password = formData.get('password') as string + + if (username !== 'admin') { + return { errors: { username: 'Invalid username. Try "admin"' } } + } + + if (password !== 'password') { + return { errors: { password: 'Invalid password. Try "password"' } } + } + + throw await setAuthOnResponse(json(redirect({ to: '/dashboard' })), '1234') + } +) + +export const Route = createFileRoute('/login')({ + component: LoginComp, +}) + +function LoginComp() { + const mutation = useMutation({ + fn: loginFn, + }) + + return ( +
+

Login

+
+
+
+ +
+
+ +
+
+ +
+
+ ) +} + +// function useAction( +// action: (formData: TypedFormData) => Promise +// ) { +// return useMutation(action) +// } + +interface TypedFormData> extends FormData { + get: (name: TKey) => TData[TKey] +} diff --git a/app/routes/merch.tsx b/app/routes/merch.tsx new file mode 100644 index 000000000..0eef03da6 --- /dev/null +++ b/app/routes/merch.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/merch')({ + loader: () => { + throw redirect({ + href: `https://cottonbureau.com/people/tanstack`, + code: 301, + }) + }, +}) diff --git a/src/routes/sponsors-embed.tsx b/app/routes/sponsors-embed.tsx similarity index 77% rename from src/routes/sponsors-embed.tsx rename to app/routes/sponsors-embed.tsx index 5ae89632e..5ba3e1742 100644 --- a/src/routes/sponsors-embed.tsx +++ b/app/routes/sponsors-embed.tsx @@ -1,15 +1,12 @@ -import { createFileRoute } from '@tanstack/react-router' import SponsorPack from '~/components/SponsorPack' -import { getSponsorsForSponsorPack } from '~/utils/sponsors.functions' +import { getSponsorsForSponsorPack } from '~/server/sponsors' +import { createFileRoute } from '@tanstack/react-router' const cacheHeaders = { - 'Cache-Control': 'public, max-age=300', - 'Cloudflare-CDN-Cache-Control': - 'public, max-age=3600, stale-while-revalidate=300', + 'Cache-Control': 'max-age=300, s-maxage=3600, stale-while-revalidate', } export const Route = createFileRoute('/sponsors-embed')({ - staleTime: Infinity, loader: () => getSponsorsForSponsorPack(), headers: () => { // Cache the entire HTML response for 5 minutes @@ -17,7 +14,6 @@ export const Route = createFileRoute('/sponsors-embed')({ }, staticData: { baseParent: true, - showNavbar: false, }, component: SponsorsEmbed, }) diff --git a/app/server/airtable.ts b/app/server/airtable.ts new file mode 100644 index 000000000..54aadcf0f --- /dev/null +++ b/app/server/airtable.ts @@ -0,0 +1,18 @@ +import Airtable from 'airtable' + +const airtable = () => new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }) + +export async function getSponsorsTable() { + const base = airtable().base('apppS8mjo4MMR3pif') + return base('sponsors') +} + +export async function getTiersTable() { + const base = airtable().base('apppS8mjo4MMR3pif') + return base('tiers') +} + +export async function getDiscordInvitesTable() { + const base = airtable().base('apppS8mjo4MMR3pif') + return base('tiers') +} diff --git a/app/server/discord-github.ts b/app/server/discord-github.ts new file mode 100644 index 000000000..7d292fbd7 --- /dev/null +++ b/app/server/discord-github.ts @@ -0,0 +1,45 @@ +import { Octokit } from '@octokit/rest' +import { linkSponsorToken } from '~/server/discord' +import { getSponsorsAndTiers } from '~/server/sponsors' + +export async function linkGithubAndDiscordUser({ githubToken, discordToken }) { + let login + + try { + const octokit = new Octokit({ + auth: githubToken, + useAgent: `TanStack.com ${githubToken}`, + }) + + const { data } = await octokit.users.getAuthenticated() + + login = data.login + } catch (err) { + console.error(err) + throw new Error('Unable to fetch GitHub user info. Please log in again.') + } + + let sponsor + try { + const { sponsors } = await getSponsorsAndTiers() + + sponsor = sponsors.find((d) => d.login == login) + } catch (err) { + throw new Error('Unable to fetch sponsor info. Please contact support.') + } + + if (!sponsor) { + throw new Error( + `TanStack sponsorship not found for GitHub user "${login}". Please sign up at https://github.com/sponsors/tannerlinsley` + ) + } + + const sponsorType = sponsor.tier.meta.sponsorType + + const message = await linkSponsorToken({ + discordToken, + sponsorType, + }) + + return message +} diff --git a/app/server/discord.ts b/app/server/discord.ts new file mode 100644 index 000000000..cfcbe63b5 --- /dev/null +++ b/app/server/discord.ts @@ -0,0 +1,169 @@ +import axios from 'axios' +import * as qss from 'qss' + +const discordClientId = '725855554362146899' +const discordClientSecret = process.env.DISCORD_APP_CLIENT_SECRET +const guildId = '719702312431386674' +const discordBaseURL = 'https://discord.com/api/' + +// const channelsBySponsorType = { +// welcome: '725435640673468500', +// fan: '803508045627654155', +// supporter: '803508117752119307', +// premierSponsor: '803508359378370600', +// } + +const rolesBySponsorType = { + fan: 'Fan', + supporter: 'Supporter', + premierSponsor: 'Premier Sponsor', +} + +const TanBotToken = process.env.DISCORD_TOKEN + +// const clientsByToken = {} + +// async function getClient(discordToken) { +// if (!discordToken) { +// throw new Error('No discord token found') +// } +// if (!clientsByToken[discordToken]) { +// const client = new Discord.Client() + +// clientsByToken[discordToken] = new Promise((resolve, reject) => { +// client.on('ready', async () => { +// console.info('Discord Connected.') +// resolve(client) +// }) +// client.login(discordToken).catch(reject) +// }) +// } + +// return clientsByToken[discordToken] +// } + +// export async function getInviteLink({ tier }) { +// await getClient() + +// const invite = await channel.createInvite({ +// maxUses: 1, +// }) + +// return `https://discord.gg/${invite.code}` +// } + +export async function linkSponsorToken({ discordToken, sponsorType }) { + if (sponsorType === 'sleep-aid') { + throw new Error( + '😔 You must be at least a "Fan" sponsor to access exclusive discord channels.' + ) + } + + if (!rolesBySponsorType[sponsorType]) { + throw new Error('Invalid sponsor type! Contact support, please!') + } + + const botClient = axios.create({ + baseURL: discordBaseURL, + headers: { + authorization: `Bot ${TanBotToken}`, + }, + }) + + const userClient = axios.create({ + baseURL: discordBaseURL, + headers: { + authorization: `Bearer ${discordToken}`, + }, + }) + + let roles, userData + try { + const { data } = await botClient.get(`/guilds/${guildId}/roles`) + roles = data + } catch (err) { + console.error(err) + throw new Error('Unable to fetch Discord roles. Please contact support!') + } + + try { + const { data } = await userClient.get('/users/@me') + userData = data + } catch (err) { + console.error(err) + throw new Error( + 'Unable to fetch Discord user info. Please contact support!' + ) + } + + let tierRole = roles.find((role) => + role.name.includes(rolesBySponsorType[sponsorType]) + ) + + if (!tierRole) { + throw new Error( + 'Could not find Discord role for sponsor tier! Please contact support.' + ) + } + + let addData + + try { + const { data } = await botClient.put( + `/guilds/${guildId}/members/${userData.id}`, + { + roles: [tierRole.id], + access_token: discordToken, + } + ) + + addData = data + } catch (err) { + throw new Error( + 'Unable to add user to TanStack Discord. Please contact support.' + ) + } + + if (!addData) { + try { + await botClient.patch(`/guilds/${guildId}/members/${userData.id}`, { + roles: [tierRole.id], + access_token: discordToken, + }) + } catch (err) { + throw new Error( + 'Could not update Discord role for user! Please contact support.' + ) + } + } + + return `You have been successfully added to the TanStack discord, along with the exclusive access to the sponsors-only "${tierRole.name}" channel!` +} + +export async function exchangeDiscordCodeForToken({ code, redirectUrl }) { + try { + const body = qss.encode({ + client_id: discordClientId, + client_secret: discordClientSecret, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUrl, + scope: 'identify guilds.join', + }) + + const { data } = await axios.post( + 'https://discord.com/api/oauth2/token', + body, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ) + + return data.access_token + } catch (err) { + console.error(err) + throw new Error('Unable to authenticate with Discord. Please log in again.') + } +} diff --git a/app/server/github.ts b/app/server/github.ts new file mode 100644 index 000000000..072c957d7 --- /dev/null +++ b/app/server/github.ts @@ -0,0 +1,58 @@ +import axios from 'axios' +import { Octokit } from '@octokit/rest' +import { graphql } from '@octokit/graphql' + +export const GITHUB_ORG = 'TanStack' + +export const octokit = new Octokit({ + auth: process.env.GITHUB_AUTH_TOKEN, + userAgent: 'TanStack.com', +}) + +export const graphqlWithAuth = graphql.defaults({ + headers: { + authorization: `token ${process.env.GITHUB_AUTH_TOKEN}`, + }, +}) + +const githubClientID = 'Iv1.3aa8d13a4a3fde91' +const githubClientSecret = 'e2340f390f956b6fbfb9c6f85100d6cfe07f29a8' + +export async function exchangeGithubCodeForToken({ code, state, redirectUrl }) { + try { + const { data } = await axios.post( + 'https://github.com/login/oauth/access_token', + { + client_id: githubClientID, + client_secret: githubClientSecret, + code, + redirect_uri: redirectUrl, + state, + }, + { + headers: { + Accept: 'application/json', + }, + } + ) + + return data.access_token + } catch (err) { + console.error(err) + throw new Error('Unable to authenticate with GitHub. Please log in again.') + } +} + +export async function getTeamBySlug(slug) { + const teams = await octokit.teams.list({ + org: GITHUB_ORG, + }) + + const sponsorsTeam = teams.data.find((x) => x.slug === slug) + + if (!sponsorsTeam) { + throw new Error(`Cannot find team "${slug}" in the organization`) + } + + return sponsorsTeam +} diff --git a/app/server/sponsors.ts b/app/server/sponsors.ts new file mode 100644 index 000000000..91c7e3f55 --- /dev/null +++ b/app/server/sponsors.ts @@ -0,0 +1,292 @@ +import { fetchCached } from '~/utils/cache.server' +import { getSponsorsTable } from '~/server/airtable' +import { GITHUB_ORG, graphqlWithAuth, octokit } from '~/server/github' +import { + getGithubTiersWithMeta, + getTierById, + updateTiersMeta, +} from '~/server/tiers' +import { createServerFn, json } from '@tanstack/start' + +export type Sponsor = { + login: string + name: string + email: string + imageUrl: string + linkUrl: string + privacyLevel: string + tier: { + id: string + monthlyPriceInDollars: number + meta: { + githubTeamSlug: string + } + } + createdAt: string +} + +// const teamsBySponsorType = { +// fan: 'Fan', +// supporter: 'Supporter', +// premierSponsor: 'Premier Sponsor', +// } + +export async function sponsorCreated({ login, newTier }) { + // newTier = await getTierById(newTier.id) + + await inviteAllSponsors() + + // if (newTier.meta.githubTeamSlug) { + // await octokit.teams.addOrUpdateMembershipForUserInOrg({ + // org: GITHUB_ORG, + // team_slug: newTier.meta.githubTeamSlug, + // username: login, + // }) + + // console.info(`invited user:${login} to team:${newTier.meta.githubTeamSlug}`) + // } +} + +export async function sponsorEdited({ login, oldTier, newTier }) { + oldTier = await getTierById(oldTier.id) + // newTier = await getTierById(newTier.id) + + await octokit.teams.removeMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: oldTier.meta.githubTeamSlug, + username: login, + }) + console.info(`removed user:${login} from team:${oldTier.meta.githubTeamSlug}`) + + await inviteAllSponsors() + // await octokit.teams.addOrUpdateMembershipForUserInOrg({ + // org: GITHUB_ORG, + // team_slug: newTier.meta.githubTeamSlug, + // username: login, + // }) + // console.info(`invited user:${login} to team:${newTier.meta.githubTeamSlug}`) +} + +export async function sponsorCancelled({ login, oldTier }) { + oldTier = await getTierById(oldTier.id) + await octokit.teams.removeMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: oldTier.meta.githubTeamSlug, + username: login, + }) + console.info(`removed user:${login} from team:${oldTier.meta.githubTeamSlug}`) +} + +async function inviteAllSponsors() { + let { sponsors } = await getSponsorsAndTiers() + + await Promise.all( + sponsors.map(async (sponsor) => { + await octokit.teams.addOrUpdateMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: sponsor.tier.meta.githubTeamSlug, + username: sponsor.login, + }) + }) + ) +} + +export async function getSponsorsAndTiers() { + const tiers = await getGithubTiersWithMeta() + await updateTiersMeta(tiers) + + let [sponsors, sponsorsMeta] = await Promise.all([ + getGithubSponsors(), + getSponsorsMeta().then((all) => all.map((d) => d.fields)), + ]) + + sponsors = sponsors.map((d) => ({ + ...d, + tier: tiers.find((tier) => tier.id == d.tier.id), + })) + + sponsorsMeta.forEach((sponsorMeta) => { + const matchingSponsor = sponsors.find((d) => d.login == sponsorMeta.login) + + if (matchingSponsor) { + Object.assign(matchingSponsor, { + name: sponsorMeta.name ?? matchingSponsor.name, + email: sponsorMeta.email ?? matchingSponsor.email, + imageUrl: sponsorMeta.imageUrl ?? matchingSponsor.imageUrl, + linkUrl: sponsorMeta.linkUrl ?? matchingSponsor.linkUrl, + privacyLevel: sponsorMeta.privacyLevel ?? matchingSponsor.privacyLevel, + }) + } else { + const tier = tiers.find((d) => d.id === sponsorMeta.tierId?.[0]) + sponsors.push({ + ...sponsorMeta, + tier, + }) + } + }) + + sponsors.sort( + (a, b) => + b.monthlyPriceInDollars - a.monthlyPriceInDollars || + (b.createdAt > a.createdAt ? -1 : 1) + ) + + return { + sponsors, + tiers, + } +} + +async function getGithubSponsors() { + let sponsors: Sponsor = [] + try { + const fetchPage = async (cursor = '') => { + const res = await graphqlWithAuth( + ` + query ($cursor: String) { + viewer { + sponsorshipsAsMaintainer(first: 100, after: $cursor, includePrivate: true) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + createdAt + sponsorEntity { + ... on User { + name + login + email + } + ... on Organization { + name + login + email + } + } + tier { + id + monthlyPriceInDollars + } + privacyLevel + } + } + } + } + } + `, + { + cursor, + } + ) + + const { + viewer: { + sponsorshipsAsMaintainer: { + pageInfo: { hasNextPage, endCursor }, + edges, + }, + }, + } = res + + sponsors = [ + ...sponsors, + ...edges.map((edge) => { + const { + node: { createdAt, sponsorEntity, tier, privacyLevel }, + } = edge + + if (!sponsorEntity) { + return null + } + + const { name, login, email } = sponsorEntity + + return { + name, + login, + email, + tier, + createdAt, + privacyLevel, + } + }), + ] + + if (hasNextPage) { + await fetchPage(endCursor) + } + } + + await fetchPage() + + return sponsors.filter(Boolean) + } catch (err: any) { + if (err.status === 401) { + console.error('Missing github credentials, returning mock data.') + return [] + } + throw err + } +} + +async function getSponsorsMeta() { + try { + const sponsorsTable = await getSponsorsTable() + + return new Promise((resolve, reject) => { + let allSponsors = [] + sponsorsTable.select().eachPage( + function page(records, fetchNextPage) { + allSponsors = [...allSponsors, ...records] + fetchNextPage() + }, + function done(err) { + if (err) { + reject(err) + } else { + resolve(allSponsors) + } + } + ) + }) + } catch (err: any) { + if (err.message === 'An API key is required to connect to Airtable') { + console.error('Missing airtable credentials, returning mock data.') + + return [] + } + throw err + } +} + +export const getSponsorsForSponsorPack = createServerFn('GET', async () => { + 'use server' + + let { sponsors } = (await fetchCached({ + key: 'sponsors', + // ttl: process.env.NODE_ENV === 'development' ? 1 : 60 * 60 * 1000, + ttl: 60 * 1000, + fn: getSponsorsAndTiers, + })) as { sponsors: Sponsor[] } + + return json( + sponsors + .filter((d) => d.privacyLevel === 'PUBLIC') + .map((d) => ({ + linkUrl: d.linkUrl, + login: d.login, + imageUrl: d.imageUrl, + name: d.name, + tier: { + monthlyPriceInDollars: d.tier?.monthlyPriceInDollars, + }, + })), + { + headers: { + 'Cache-Control': 'max-age=300, s-maxage=3600, stale-while-revalidate', + }, + } + ) +}) diff --git a/app/server/tiers.ts b/app/server/tiers.ts new file mode 100644 index 000000000..a13441286 --- /dev/null +++ b/app/server/tiers.ts @@ -0,0 +1,140 @@ +import { getTiersTable } from '~/server/airtable' +import { graphqlWithAuth } from '~/server/github' + +async function getTiersMeta() { + try { + const tiersTable = await getTiersTable() + + const tiers = await new Promise((resolve, reject) => { + let allTiers = [] + tiersTable.select().eachPage( + function page(records, fetchNextPage) { + allTiers = [...allTiers, ...records] + fetchNextPage() + }, + function done(err) { + if (err) { + reject(err) + } else { + resolve(allTiers) + } + } + ) + }) + + return tiers + } catch (err: any) { + if (err.message === 'An API key is required to connect to Airtable') { + console.error('Missing airtable credentials, returning mock data.') + return [] + } + throw err + } +} + +async function createTiersMeta(tiersMeta) { + const tiersTable = await getTiersTable() + + return new Promise((resolve, reject) => { + tiersTable.create( + tiersMeta.map((d) => ({ fields: d })), + function (err) { + if (err) { + return reject(err) + } + resolve() + } + ) + }) + // return Promise.resolve() +} + +export async function getTierById(tierId) { + const tiers = await getGithubTiers() + const tier = tiers.find((d) => d.id == tierId) + + if (!tier) { + throw new Error(`Could not find tier with id: ${tierId}`) + } + + const tiersMeta = await getTiersMeta() + const tierMeta = tiersMeta.find((d) => d.fields.id === tierId) + + if (!tierMeta) { + throw new Error(`Could not find tierMeta with id: ${tierId}`) + } + + return { + ...tier, + meta: tierMeta, + } +} + +export async function getGithubTiers() { + try { + const res: any = await graphqlWithAuth( + `query { + viewer { + sponsorshipsAsMaintainer(first: 1) { + nodes { + sponsorable { + sponsorsListing { + tiers(first: 100) { + nodes { + id + name + description + descriptionHTML + monthlyPriceInDollars + } + } + } + } + } + } + } + }` + ) + + return res.viewer.sponsorshipsAsMaintainer.nodes[0].sponsorable + .sponsorsListing.tiers.nodes + } catch (err: any) { + if (err?.status === 401) { + console.error('Missing github credentials, returning mock data.') + return [] + } + + throw err + } +} + +export async function getGithubTiersWithMeta() { + const githubTiers = await getGithubTiers() + const tiersMeta = await getTiersMeta() + + return githubTiers.map((d) => ({ + ...d, + meta: tiersMeta.find((meta) => meta.fields?.id == d.id)?.fields, + })) +} + +export async function updateTiersMeta(githubTiers) { + const tiersMeta = await getTiersMeta() + + await Promise.all( + tiersMeta.map((tierMeta) => { + const newTier = githubTiers.find((d) => d.id === tierMeta.fields.id) + if (newTier) { + githubTiers = githubTiers.filter((d) => d !== newTier) + return tierMeta.updateFields({ + name: newTier.name, + }) + } + return tierMeta.destroy() + }) + ) + + if (githubTiers?.length) { + await createTiersMeta(githubTiers.map((d) => ({ id: d.id, name: d.name }))) + } +} diff --git a/app/ssr.tsx b/app/ssr.tsx new file mode 100644 index 000000000..79ac23f57 --- /dev/null +++ b/app/ssr.tsx @@ -0,0 +1,12 @@ +import { + createRequestHandler, + defaultStreamHandler, +} from '@tanstack/start/server' +import { getRouterManifest } from '@tanstack/start/router-manifest' + +import { createRouter } from './router' + +export default createRequestHandler({ + createRouter, + getRouterManifest, +})(defaultStreamHandler) diff --git a/app/styles/app.css b/app/styles/app.css new file mode 100644 index 000000000..9dc4b400d --- /dev/null +++ b/app/styles/app.css @@ -0,0 +1,463 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html, + body { + @apply text-gray-900 bg-gray-50 dark:bg-gray-950 dark:text-gray-200; + } + + .using-mouse * { + outline: none !important; + } + + @media (prefers-color-scheme: dark) { + * { + color-scheme: dark; + } + } + /* * { + scrollbar-color: theme(colors.gray.500) theme(colors.gray.100); + } + + *::-webkit-scrollbar, + * scrollbar { + width: 1rem; + height: 1rem; + } + + *::-webkit-scrollbar-track, + * scrollbar-track { + background: theme(colors.gray.100); + } + + *::-webkit-scrollbar-thumb, + * scrollbar-thumb { + background: theme(colors.gray.300); + border-radius: 0.5rem; + border: 3px solid theme(colors.gray.100); + } + + @media (prefers-color-scheme: dark) { + * { + scrollbar-color: theme(colors.gray.500) theme(colors.gray.800); + } + + *::-webkit-scrollbar, + * scrollbar { + width: 1rem; + height: 1rem; + } + + *::-webkit-scrollbar-track, + * scrollbar-track { + background: theme(colors.gray.800); + } + + *::-webkit-scrollbar-thumb, + * scrollbar-thumb { + background: theme(colors.gray.600); + border-radius: 0.5rem; + border: 3px solid theme(colors.gray.800); + } + } */ + + [disabled] { + @apply opacity-50 pointer-events-none; + } + + #docs-details summary::-webkit-details-marker { + display: none; + } + + #docs-details .icon-close { + display: none; + } + + #docs-details[open] .icon-close { + display: block; + } + + #docs-details[open] .icon-open { + display: none; + } + + #docs-details[open] > summary + * { + height: 80vh; + } + + .anchor-heading { + text-decoration: none !important; + display: block; + } + + .anchor-heading > *:after { + content: '#'; + @apply relative opacity-0 ml-2 transition duration-100; + } + + .anchor-heading:hover > *:after { + @apply opacity-50; + } + + :has(+ .anchor-heading) { + margin-bottom: 0 !important; + } + + .anchor-heading + * { + margin-top: 0 !important; + } + + .carbon-small { + pointer-events: none; + } + + .carbon-small #carbonads { + @apply pointer-events-none; + } + + .carbon-small .carbon-outer { + @apply pointer-events-none; + } + + .carbon-small .carbon-wrap { + @apply flex flex-col; + } + + .carbon-small .carbon-wrap .carbon-img { + @apply w-[50%] pt-2 !pointer-events-auto rounded-tr-lg border-t border-r border-gray-500 border-opacity-10 overflow-hidden; + } + + .carbon-small .carbon-wrap .carbon-img img { + @apply w-full !max-w-full; + } + + .carbon-small .carbon-wrap .carbon-text { + @apply bg-white dark:bg-gray-800 rounded-tr-lg !pb-6 !m-0 !pointer-events-auto border-t border-r border-gray-500 border-opacity-10; + } + + .carbon-small .carbon-wrap .carbon-poweredby { + @apply absolute bottom-0 right-0; + } +} + +/* https://github.com/shikijs/twoslash/tree/main/packages/remark-shiki-twoslash#plugin-setup */ +pre { + /* All code samples get a grey border, twoslash ones get a different color */ + @apply bg-white text-black p-2 border border-gray-500/30 mb-4 rounded-md; + @apply overflow-x-auto relative leading-tight; +} +pre.shiki { + overflow-x: auto; +} +pre.shiki:hover .dim { + opacity: 1; +} +pre.shiki div.dim { + opacity: 0.5; +} +pre.shiki div.dim, +pre.shiki div.highlight { + margin: 0; + padding: 0; +} +pre.shiki div.highlight { + opacity: 1; + background-color: #f1f8ff; +} +pre.shiki div.line { + min-height: 1rem; +} + +/** Don't show the language identifiers */ +pre.shiki .language-id { + display: none; +} + +pre.has-diff span.remove { + background-color: #ff000036; +} + +pre.has-diff span.add { + background-color: #00ff0036; +} +/* Visually differentiates twoslash code samples */ +pre.twoslash { + border-color: #719af4; +} + +/** When you mouse over the pre, show the underlines */ +pre.twoslash:hover data-lsp { + border-color: #747474; +} + +/** The tooltip-like which provides the LSP response */ +pre.twoslash data-lsp:hover::before { + content: attr(lsp); + position: absolute; + transform: translate(0, 1rem); + + background-color: #3f3f3f; + color: #fff; + text-align: left; + padding: 5px 8px; + border-radius: 2px; + font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, Courier New, monospace; + font-size: 14px; + white-space: pre-wrap; + z-index: 100; +} + +pre .code-container { + overflow: auto; +} +/* The try button */ +pre .code-container > a { + position: absolute; + right: 8px; + bottom: 8px; + border-radius: 4px; + border: 1px solid #719af4; + padding: 0 8px; + color: #719af4; + text-decoration: none; + opacity: 0; + transition-timing-function: ease; + transition: opacity 0.3s; +} +/* Respect no animations */ +@media (prefers-reduced-motion: reduce) { + pre .code-container > a { + transition: none; + } +} +pre .code-container > a:hover { + color: white; + background-color: #719af4; +} +pre .code-container:hover a { + opacity: 1; +} + +pre code { + font-size: 12px; + font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, Courier New, monospace; + white-space: pre; + -webkit-overflow-scrolling: touch; +} +pre code a { + text-decoration: none; +} +pre data-err { + /* Extracted from VS Code */ + background: url("data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%206%203'%20enable-background%3D'new%200%200%206%203'%20height%3D'3'%20width%3D'6'%3E%3Cg%20fill%3D'%23c94824'%3E%3Cpolygon%20points%3D'5.5%2C0%202.5%2C3%201.1%2C3%204.1%2C0'%2F%3E%3Cpolygon%20points%3D'4%2C0%206%2C2%206%2C0.6%205.4%2C0'%2F%3E%3Cpolygon%20points%3D'0%2C2%201%2C3%202.4%2C3%200%2C0.6'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E") + repeat-x bottom left; + padding-bottom: 3px; +} +pre .query { + margin-bottom: 10px; + color: #137998; + display: inline-block; +} + +/* In order to have the 'popped out' style design and to not break the layout +/* we need to place a fake and un-selectable copy of the error which _isn't_ broken out +/* behind the actual error message. + +/* This sections keeps both of those two in in sync */ + +pre .error, +pre .error-behind { + margin-left: -14px; + margin-top: 8px; + margin-bottom: 4px; + padding: 6px; + padding-left: 14px; + width: calc(100% - 20px); + white-space: pre-wrap; + display: block; +} +pre .error { + position: absolute; + background-color: #fee; + border-left: 2px solid #bf1818; + /* Give the space to the error code */ + display: flex; + align-items: center; + color: black; +} +pre .error .code { + display: none; +} +pre .error-behind { + user-select: none; + visibility: transparent; + color: #fee; +} +/* Queries */ +pre .arrow { + /* Transparent background */ + background-color: #eee; + position: relative; + top: -7px; + margin-left: 0.1rem; + /* Edges */ + border-left: 1px solid #eee; + border-top: 1px solid #eee; + transform: translateY(25%) rotate(45deg); + /* Size */ + height: 8px; + width: 8px; +} +pre .popover { + margin-bottom: 10px; + background-color: #eee; + display: inline-block; + padding: 0 0.5rem 0.3rem; + margin-top: 10px; + border-radius: 3px; +} +/* Completion */ +pre .inline-completions ul.dropdown { + display: inline-block; + position: absolute; + width: 240px; + background-color: gainsboro; + color: grey; + padding-top: 4px; + font-family: var(--code-font); + font-size: 0.8rem; + margin: 0; + padding: 0; + border-left: 4px solid #4b9edd; +} +pre .inline-completions ul.dropdown::before { + background-color: #4b9edd; + width: 2px; + position: absolute; + top: -1.2rem; + left: -3px; + content: ' '; +} +pre .inline-completions ul.dropdown li { + overflow-x: hidden; + padding-left: 4px; + margin-bottom: 4px; +} +pre .inline-completions ul.dropdown li.deprecated { + text-decoration: line-through; +} +pre .inline-completions ul.dropdown li span.result-found { + color: #4b9edd; +} +pre .inline-completions ul.dropdown li span.result { + width: 100px; + color: black; + display: inline-block; +} +.dark-theme .markdown pre { + background-color: #d8d8d8; + border-color: #ddd; + filter: invert(98%) hue-rotate(180deg); +} +data-lsp { + /* Ensures there's no 1px jump when the hover happens */ + border-bottom: 1px dotted transparent; + /* Fades in unobtrusively */ + transition-timing-function: ease; + transition: border-color 0.3s; +} +/* Respect people's wishes to not have animations */ +@media (prefers-reduced-motion: reduce) { + data-lsp { + transition: none; + } +} + +/** Annotations support, providing a tool for meta commentary */ +.tag-container { + position: relative; +} +.tag-container .twoslash-annotation { + position: absolute; + font-family: 'JetBrains Mono', Menlo, Monaco, Consolas, Courier New, monospace; + right: -10px; + /** Default annotation text to 200px */ + width: 200px; + color: #187abf; + background-color: #fcf3d9 bb; +} +.tag-container .twoslash-annotation p { + text-align: left; + font-size: 0.8rem; + line-height: 0.9rem; +} +.tag-container .twoslash-annotation svg { + float: left; + margin-left: -44px; +} +.tag-container .twoslash-annotation.left { + right: auto; + left: -200px; +} +.tag-container .twoslash-annotation.left svg { + float: right; + margin-right: -5px; +} + +/** Support for showing console log/warn/errors inline */ +pre .logger { + display: flex; + align-items: center; + color: black; + padding: 6px; + padding-left: 8px; + width: calc(100% - 19px); + white-space: pre-wrap; +} +pre .logger svg { + margin-right: 9px; +} +pre .logger.error-log { + background-color: #fee; + border-left: 2px solid #bf1818; +} +pre .logger.warn-log { + background-color: #ffe; + border-left: 2px solid #eae662; +} +pre .logger.log-log { + background-color: #e9e9e9; + border-left: 2px solid #ababab; +} +pre .logger.log-log svg { + margin-left: 6px; + margin-right: 9px; +} + +@media (prefers-color-scheme: light) { + .shiki.tokyo-night { + display: none; + } +} + +@media (prefers-color-scheme: dark) { + .shiki.github-light { + display: none; + } +} + +/* TanStack Router Devtools */ + +.TanStackRouterDevtools > button { + @apply rotate-90 origin-top-right -translate-y-[50px] translate-x-2 rounded-t-none bg-white dark:bg-gray-900; + @apply border-t-0 border-gray-500/10 shadow-xl text-gray-800; +} + +/* Hubspot */ + +#hubspot-messages-iframe-container { + @apply translate-x-[10px] translate-y-[10px]; + @apply dark:[color-scheme:dark]; +} diff --git a/app/styles/app.generated.css b/app/styles/app.generated.css new file mode 100644 index 000000000..061768c13 --- /dev/null +++ b/app/styles/app.generated.css @@ -0,0 +1,4165 @@ +/* +! tailwindcss v3.1.6 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +*/ + +html { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + /* 4 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, +textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input:-ms-input-placeholder, +textarea:-ms-input-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role='button'] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +html, +body { + --tw-bg-opacity: 1; + background-color: rgb(249 250 251 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +@media (prefers-color-scheme: dark) { + html, + body { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); + } + + * { + color-scheme: dark; + } +} + +/* * { + scrollbar-color: theme(colors.gray.500) theme(colors.gray.100); + } + + *::-webkit-scrollbar, + * scrollbar { + width: 1rem; + height: 1rem; + } + + *::-webkit-scrollbar-track, + * scrollbar-track { + background: theme(colors.gray.100); + } + + *::-webkit-scrollbar-thumb, + * scrollbar-thumb { + background: theme(colors.gray.300); + border-radius: 0.5rem; + border: 3px solid theme(colors.gray.100); + } + + @media (prefers-color-scheme: dark) { + * { + scrollbar-color: theme(colors.gray.500) theme(colors.gray.800); + } + + *::-webkit-scrollbar, + * scrollbar { + width: 1rem; + height: 1rem; + } + + *::-webkit-scrollbar-track, + * scrollbar-track { + background: theme(colors.gray.800); + } + + *::-webkit-scrollbar-thumb, + * scrollbar-thumb { + background: theme(colors.gray.600); + border-radius: 0.5rem; + border: 3px solid theme(colors.gray.800); + } + } */ + +[disabled] { + pointer-events: none; + opacity: 0.5; +} + +#docs-details summary::-webkit-details-marker { + display: none; +} + +#docs-details .icon-close { + display: none; +} + +#docs-details[open] .icon-close { + display: block; +} + +#docs-details[open] .icon-open { + display: none; +} + +#docs-details[open] > summary + * { + height: 80vh; +} + +.anchor-heading { + text-decoration: none !important; +} + +.anchor-heading > *:after { + content: '#'; + position: relative; + margin-left: 0.5rem; + opacity: 0; + transition-property: color, background-color, border-color, fill, stroke, + opacity, box-shadow, transform, filter, -webkit-text-decoration-color, + -webkit-backdrop-filter; + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + backdrop-filter; + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 100ms; +} + +.anchor-heading:hover > *:after { + opacity: 0.5; +} + +*, +::before, +::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::-webkit-backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.prose { + color: var(--tw-prose-body); + max-width: 65ch; +} + +.prose :where([class~='lead']):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-lead); + font-size: 1.25em; + line-height: 1.6; + margin-top: 1.2em; + margin-bottom: 1.2em; +} + +.prose :where(a):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-links); + text-decoration: underline; + font-weight: 500; +} + +.prose :where(strong):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-bold); + font-weight: 600; +} + +.prose :where(a strong):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(blockquote strong):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(thead th strong):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(ol):not(:where([class~='not-prose'] *)) { + list-style-type: decimal; + margin-top: 1.25em; + margin-bottom: 1.25em; + padding-left: 1.625em; +} + +.prose :where(ol[type='A']):not(:where([class~='not-prose'] *)) { + list-style-type: upper-alpha; +} + +.prose :where(ol[type='a']):not(:where([class~='not-prose'] *)) { + list-style-type: lower-alpha; +} + +.prose :where(ol[type='A' s]):not(:where([class~='not-prose'] *)) { + list-style-type: upper-alpha; +} + +.prose :where(ol[type='a' s]):not(:where([class~='not-prose'] *)) { + list-style-type: lower-alpha; +} + +.prose :where(ol[type='I']):not(:where([class~='not-prose'] *)) { + list-style-type: upper-roman; +} + +.prose :where(ol[type='i']):not(:where([class~='not-prose'] *)) { + list-style-type: lower-roman; +} + +.prose :where(ol[type='I' s]):not(:where([class~='not-prose'] *)) { + list-style-type: upper-roman; +} + +.prose :where(ol[type='i' s]):not(:where([class~='not-prose'] *)) { + list-style-type: lower-roman; +} + +.prose :where(ol[type='1']):not(:where([class~='not-prose'] *)) { + list-style-type: decimal; +} + +.prose :where(ul):not(:where([class~='not-prose'] *)) { + list-style-type: disc; + margin-top: 1.25em; + margin-bottom: 1.25em; + padding-left: 1.625em; +} + +.prose :where(ol > li):not(:where([class~='not-prose'] *))::marker { + font-weight: 400; + color: var(--tw-prose-counters); +} + +.prose :where(ul > li):not(:where([class~='not-prose'] *))::marker { + color: var(--tw-prose-bullets); +} + +.prose :where(hr):not(:where([class~='not-prose'] *)) { + border-color: var(--tw-prose-hr); + border-top-width: 1px; + margin-top: 3em; + margin-bottom: 3em; +} + +.prose :where(blockquote):not(:where([class~='not-prose'] *)) { + font-weight: 500; + font-style: italic; + color: var(--tw-prose-quotes); + border-left-width: 0.25rem; + border-left-color: var(--tw-prose-quote-borders); + quotes: '\201C''\201D''\2018''\2019'; + margin-top: 1.6em; + margin-bottom: 1.6em; + padding-left: 1em; +} + +.prose + :where(blockquote p:first-of-type):not( + :where([class~='not-prose'] *) + )::before { + content: open-quote; +} + +.prose + :where(blockquote p:last-of-type):not(:where([class~='not-prose'] *))::after { + content: close-quote; +} + +.prose :where(h1):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-headings); + font-weight: 800; + font-size: 2.25em; + margin-top: 0; + margin-bottom: 0.8888889em; + line-height: 1.1111111; +} + +.prose :where(h1 strong):not(:where([class~='not-prose'] *)) { + font-weight: 900; + color: inherit; +} + +.prose :where(h2):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-headings); + font-weight: 700; + font-size: 1.5em; + margin-top: 2em; + margin-bottom: 1em; + line-height: 1.3333333; +} + +.prose :where(h2 strong):not(:where([class~='not-prose'] *)) { + font-weight: 800; + color: inherit; +} + +.prose :where(h3):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-headings); + font-weight: 600; + font-size: 1.25em; + margin-top: 1.6em; + margin-bottom: 0.6em; + line-height: 1.6; +} + +.prose :where(h3 strong):not(:where([class~='not-prose'] *)) { + font-weight: 700; + color: inherit; +} + +.prose :where(h4):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-headings); + font-weight: 600; + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.5; +} + +.prose :where(h4 strong):not(:where([class~='not-prose'] *)) { + font-weight: 700; + color: inherit; +} + +.prose :where(img):not(:where([class~='not-prose'] *)) { + margin-top: 2em; + margin-bottom: 2em; +} + +.prose :where(figure > *):not(:where([class~='not-prose'] *)) { + margin-top: 0; + margin-bottom: 0; +} + +.prose :where(figcaption):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-captions); + font-size: 0.875em; + line-height: 1.4285714; + margin-top: 0.8571429em; +} + +.prose :where(code):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-code); + font-weight: 600; + font-size: 0.875em; +} + +.prose :where(code):not(:where([class~='not-prose'] *))::before { + content: '`'; +} + +.prose :where(code):not(:where([class~='not-prose'] *))::after { + content: '`'; +} + +.prose :where(a code):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(h1 code):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(h2 code):not(:where([class~='not-prose'] *)) { + color: inherit; + font-size: 0.875em; +} + +.prose :where(h3 code):not(:where([class~='not-prose'] *)) { + color: inherit; + font-size: 0.9em; +} + +.prose :where(h4 code):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(blockquote code):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(thead th code):not(:where([class~='not-prose'] *)) { + color: inherit; +} + +.prose :where(pre):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-pre-code); + background-color: var(--tw-prose-pre-bg); + overflow-x: auto; + font-weight: 400; + font-size: 0.875em; + line-height: 1.7142857; + margin-top: 1.7142857em; + margin-bottom: 1.7142857em; + border-radius: 0.375rem; + padding-top: 0.8571429em; + padding-right: 1.1428571em; + padding-bottom: 0.8571429em; + padding-left: 1.1428571em; +} + +.prose :where(pre code):not(:where([class~='not-prose'] *)) { + background-color: transparent; + border-width: 0; + border-radius: 0; + padding: 0; + font-weight: inherit; + color: inherit; + font-size: inherit; + font-family: inherit; + line-height: inherit; +} + +.prose :where(pre code):not(:where([class~='not-prose'] *))::before { + content: none; +} + +.prose :where(pre code):not(:where([class~='not-prose'] *))::after { + content: none; +} + +.prose :where(table):not(:where([class~='not-prose'] *)) { + width: 100%; + table-layout: auto; + text-align: left; + margin-top: 2em; + margin-bottom: 2em; + font-size: 0.875em; + line-height: 1.7142857; +} + +.prose :where(thead):not(:where([class~='not-prose'] *)) { + border-bottom-width: 1px; + border-bottom-color: var(--tw-prose-th-borders); +} + +.prose :where(thead th):not(:where([class~='not-prose'] *)) { + color: var(--tw-prose-headings); + font-weight: 600; + vertical-align: bottom; + padding-right: 0.5714286em; + padding-bottom: 0.5714286em; + padding-left: 0.5714286em; +} + +.prose :where(tbody tr):not(:where([class~='not-prose'] *)) { + border-bottom-width: 1px; + border-bottom-color: var(--tw-prose-td-borders); +} + +.prose :where(tbody tr:last-child):not(:where([class~='not-prose'] *)) { + border-bottom-width: 0; +} + +.prose :where(tbody td):not(:where([class~='not-prose'] *)) { + vertical-align: baseline; +} + +.prose :where(tfoot):not(:where([class~='not-prose'] *)) { + border-top-width: 1px; + border-top-color: var(--tw-prose-th-borders); +} + +.prose :where(tfoot td):not(:where([class~='not-prose'] *)) { + vertical-align: top; +} + +.prose { + --tw-prose-body: #374151; + --tw-prose-headings: #111827; + --tw-prose-lead: #4b5563; + --tw-prose-links: #111827; + --tw-prose-bold: #111827; + --tw-prose-counters: #6b7280; + --tw-prose-bullets: #d1d5db; + --tw-prose-hr: #e5e7eb; + --tw-prose-quotes: #111827; + --tw-prose-quote-borders: #e5e7eb; + --tw-prose-captions: #6b7280; + --tw-prose-code: #111827; + --tw-prose-pre-code: #e5e7eb; + --tw-prose-pre-bg: #1f2937; + --tw-prose-th-borders: #d1d5db; + --tw-prose-td-borders: #e5e7eb; + --tw-prose-invert-body: #d1d5db; + --tw-prose-invert-headings: #fff; + --tw-prose-invert-lead: #9ca3af; + --tw-prose-invert-links: #fff; + --tw-prose-invert-bold: #fff; + --tw-prose-invert-counters: #9ca3af; + --tw-prose-invert-bullets: #4b5563; + --tw-prose-invert-hr: #374151; + --tw-prose-invert-quotes: #f3f4f6; + --tw-prose-invert-quote-borders: #374151; + --tw-prose-invert-captions: #9ca3af; + --tw-prose-invert-code: #fff; + --tw-prose-invert-pre-code: #d1d5db; + --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); + --tw-prose-invert-th-borders: #4b5563; + --tw-prose-invert-td-borders: #374151; + font-size: 1rem; + line-height: 1.75; +} + +.prose :where(p):not(:where([class~='not-prose'] *)) { + margin-top: 1.25em; + margin-bottom: 1.25em; +} + +.prose :where(video):not(:where([class~='not-prose'] *)) { + margin-top: 2em; + margin-bottom: 2em; +} + +.prose :where(figure):not(:where([class~='not-prose'] *)) { + margin-top: 2em; + margin-bottom: 2em; +} + +.prose :where(li):not(:where([class~='not-prose'] *)) { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.prose :where(ol > li):not(:where([class~='not-prose'] *)) { + padding-left: 0.375em; +} + +.prose :where(ul > li):not(:where([class~='not-prose'] *)) { + padding-left: 0.375em; +} + +.prose :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.75em; + margin-bottom: 0.75em; +} + +.prose + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.25em; +} + +.prose + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.25em; +} + +.prose + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.25em; +} + +.prose + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.25em; +} + +.prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~='not-prose'] *)) { + margin-top: 0.75em; + margin-bottom: 0.75em; +} + +.prose :where(hr + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose :where(h2 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose :where(h3 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose :where(h4 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose :where(thead th:first-child):not(:where([class~='not-prose'] *)) { + padding-left: 0; +} + +.prose :where(thead th:last-child):not(:where([class~='not-prose'] *)) { + padding-right: 0; +} + +.prose :where(tbody td, tfoot td):not(:where([class~='not-prose'] *)) { + padding-top: 0.5714286em; + padding-right: 0.5714286em; + padding-bottom: 0.5714286em; + padding-left: 0.5714286em; +} + +.prose + :where(tbody td:first-child, tfoot td:first-child):not( + :where([class~='not-prose'] *) + ) { + padding-left: 0; +} + +.prose + :where(tbody td:last-child, tfoot td:last-child):not( + :where([class~='not-prose'] *) + ) { + padding-right: 0; +} + +.prose :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-sm { + font-size: 0.875rem; + line-height: 1.7142857; +} + +.prose-sm :where(p):not(:where([class~='not-prose'] *)) { + margin-top: 1.1428571em; + margin-bottom: 1.1428571em; +} + +.prose-sm :where([class~='lead']):not(:where([class~='not-prose'] *)) { + font-size: 1.2857143em; + line-height: 1.5555556; + margin-top: 0.8888889em; + margin-bottom: 0.8888889em; +} + +.prose-sm :where(blockquote):not(:where([class~='not-prose'] *)) { + margin-top: 1.3333333em; + margin-bottom: 1.3333333em; + padding-left: 1.1111111em; +} + +.prose-sm :where(h1):not(:where([class~='not-prose'] *)) { + font-size: 2.1428571em; + margin-top: 0; + margin-bottom: 0.8em; + line-height: 1.2; +} + +.prose-sm :where(h2):not(:where([class~='not-prose'] *)) { + font-size: 1.4285714em; + margin-top: 1.6em; + margin-bottom: 0.8em; + line-height: 1.4; +} + +.prose-sm :where(h3):not(:where([class~='not-prose'] *)) { + font-size: 1.2857143em; + margin-top: 1.5555556em; + margin-bottom: 0.4444444em; + line-height: 1.5555556; +} + +.prose-sm :where(h4):not(:where([class~='not-prose'] *)) { + margin-top: 1.4285714em; + margin-bottom: 0.5714286em; + line-height: 1.4285714; +} + +.prose-sm :where(img):not(:where([class~='not-prose'] *)) { + margin-top: 1.7142857em; + margin-bottom: 1.7142857em; +} + +.prose-sm :where(video):not(:where([class~='not-prose'] *)) { + margin-top: 1.7142857em; + margin-bottom: 1.7142857em; +} + +.prose-sm :where(figure):not(:where([class~='not-prose'] *)) { + margin-top: 1.7142857em; + margin-bottom: 1.7142857em; +} + +.prose-sm :where(figure > *):not(:where([class~='not-prose'] *)) { + margin-top: 0; + margin-bottom: 0; +} + +.prose-sm :where(figcaption):not(:where([class~='not-prose'] *)) { + font-size: 0.8571429em; + line-height: 1.3333333; + margin-top: 0.6666667em; +} + +.prose-sm :where(code):not(:where([class~='not-prose'] *)) { + font-size: 0.8571429em; +} + +.prose-sm :where(h2 code):not(:where([class~='not-prose'] *)) { + font-size: 0.9em; +} + +.prose-sm :where(h3 code):not(:where([class~='not-prose'] *)) { + font-size: 0.8888889em; +} + +.prose-sm :where(pre):not(:where([class~='not-prose'] *)) { + font-size: 0.8571429em; + line-height: 1.6666667; + margin-top: 1.6666667em; + margin-bottom: 1.6666667em; + border-radius: 0.25rem; + padding-top: 0.6666667em; + padding-right: 1em; + padding-bottom: 0.6666667em; + padding-left: 1em; +} + +.prose-sm :where(ol):not(:where([class~='not-prose'] *)) { + margin-top: 1.1428571em; + margin-bottom: 1.1428571em; + padding-left: 1.5714286em; +} + +.prose-sm :where(ul):not(:where([class~='not-prose'] *)) { + margin-top: 1.1428571em; + margin-bottom: 1.1428571em; + padding-left: 1.5714286em; +} + +.prose-sm :where(li):not(:where([class~='not-prose'] *)) { + margin-top: 0.2857143em; + margin-bottom: 0.2857143em; +} + +.prose-sm :where(ol > li):not(:where([class~='not-prose'] *)) { + padding-left: 0.4285714em; +} + +.prose-sm :where(ul > li):not(:where([class~='not-prose'] *)) { + padding-left: 0.4285714em; +} + +.prose-sm :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.5714286em; + margin-bottom: 0.5714286em; +} + +.prose-sm + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.1428571em; +} + +.prose-sm + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.1428571em; +} + +.prose-sm + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.1428571em; +} + +.prose-sm + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.1428571em; +} + +.prose-sm + :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~='not-prose'] *)) { + margin-top: 0.5714286em; + margin-bottom: 0.5714286em; +} + +.prose-sm :where(hr):not(:where([class~='not-prose'] *)) { + margin-top: 2.8571429em; + margin-bottom: 2.8571429em; +} + +.prose-sm :where(hr + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-sm :where(h2 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-sm :where(h3 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-sm :where(h4 + *):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-sm :where(table):not(:where([class~='not-prose'] *)) { + font-size: 0.8571429em; + line-height: 1.5; +} + +.prose-sm :where(thead th):not(:where([class~='not-prose'] *)) { + padding-right: 1em; + padding-bottom: 0.6666667em; + padding-left: 1em; +} + +.prose-sm :where(thead th:first-child):not(:where([class~='not-prose'] *)) { + padding-left: 0; +} + +.prose-sm :where(thead th:last-child):not(:where([class~='not-prose'] *)) { + padding-right: 0; +} + +.prose-sm :where(tbody td, tfoot td):not(:where([class~='not-prose'] *)) { + padding-top: 0.6666667em; + padding-right: 1em; + padding-bottom: 0.6666667em; + padding-left: 1em; +} + +.prose-sm + :where(tbody td:first-child, tfoot td:first-child):not( + :where([class~='not-prose'] *) + ) { + padding-left: 0; +} + +.prose-sm + :where(tbody td:last-child, tfoot td:last-child):not( + :where([class~='not-prose'] *) + ) { + padding-right: 0; +} + +.prose-sm :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-sm :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-base :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.75em; + margin-bottom: 0.75em; +} + +.prose-base + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.25em; +} + +.prose-base + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.25em; +} + +.prose-base + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.25em; +} + +.prose-base + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.25em; +} + +.prose-base :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-base :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-lg :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.8888889em; + margin-bottom: 0.8888889em; +} + +.prose-lg + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.3333333em; +} + +.prose-lg + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.3333333em; +} + +.prose-lg + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.3333333em; +} + +.prose-lg + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.3333333em; +} + +.prose-lg :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-lg :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-xl :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.8em; + margin-bottom: 0.8em; +} + +.prose-xl + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.2em; +} + +.prose-xl + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.2em; +} + +.prose-xl + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.2em; +} + +.prose-xl + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.2em; +} + +.prose-xl :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-xl :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-2xl :where(.prose > ul > li p):not(:where([class~='not-prose'] *)) { + margin-top: 0.8333333em; + margin-bottom: 0.8333333em; +} + +.prose-2xl + :where(.prose > ul > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.3333333em; +} + +.prose-2xl + :where(.prose > ul > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.3333333em; +} + +.prose-2xl + :where(.prose > ol > li > *:first-child):not(:where([class~='not-prose'] *)) { + margin-top: 1.3333333em; +} + +.prose-2xl + :where(.prose > ol > li > *:last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 1.3333333em; +} + +.prose-2xl :where(.prose > :first-child):not(:where([class~='not-prose'] *)) { + margin-top: 0; +} + +.prose-2xl :where(.prose > :last-child):not(:where([class~='not-prose'] *)) { + margin-bottom: 0; +} + +.prose-gray { + --tw-prose-body: #374151; + --tw-prose-headings: #111827; + --tw-prose-lead: #4b5563; + --tw-prose-links: #111827; + --tw-prose-bold: #111827; + --tw-prose-counters: #6b7280; + --tw-prose-bullets: #d1d5db; + --tw-prose-hr: #e5e7eb; + --tw-prose-quotes: #111827; + --tw-prose-quote-borders: #e5e7eb; + --tw-prose-captions: #6b7280; + --tw-prose-code: #111827; + --tw-prose-pre-code: #e5e7eb; + --tw-prose-pre-bg: #1f2937; + --tw-prose-th-borders: #d1d5db; + --tw-prose-td-borders: #e5e7eb; + --tw-prose-invert-body: #d1d5db; + --tw-prose-invert-headings: #fff; + --tw-prose-invert-lead: #9ca3af; + --tw-prose-invert-links: #fff; + --tw-prose-invert-bold: #fff; + --tw-prose-invert-counters: #9ca3af; + --tw-prose-invert-bullets: #4b5563; + --tw-prose-invert-hr: #374151; + --tw-prose-invert-quotes: #f3f4f6; + --tw-prose-invert-quote-borders: #374151; + --tw-prose-invert-captions: #9ca3af; + --tw-prose-invert-code: #fff; + --tw-prose-invert-pre-code: #d1d5db; + --tw-prose-invert-pre-bg: rgb(0 0 0 / 50%); + --tw-prose-invert-th-borders: #4b5563; + --tw-prose-invert-td-borders: #374151; +} + +.pointer-events-none { + pointer-events: none; +} + +.visible { + visibility: visible; +} + +.fixed { + position: fixed; +} + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.sticky { + position: -webkit-sticky; + position: sticky; +} + +.inset-y-0 { + top: 0px; + bottom: 0px; +} + +.top-2 { + top: 0.5rem; +} + +.left-1\/2 { + left: 50%; +} + +.right-0 { + right: 0px; +} + +.-top-3 { + top: -0.75rem; +} + +.right-2 { + right: 0.5rem; +} + +.top-0 { + top: 0px; +} + +.bottom-0 { + bottom: 0px; +} + +.left-0 { + left: 0px; +} + +.top-1\/2 { + top: 50%; +} + +.top-\[1px\] { + top: 1px; +} + +.top-16 { + top: 4rem; +} + +.left-1\/4 { + left: 25%; +} + +.right-1\/4 { + right: 25%; +} + +.top-1\/4 { + top: 25%; +} + +.bottom-1\/4 { + bottom: 25%; +} + +.right-\[-48px\] { + right: -48px; +} + +.z-30 { + z-index: 30; +} + +.z-10 { + z-index: 10; +} + +.z-20 { + z-index: 20; +} + +.z-0 { + z-index: 0; +} + +.col-span-2 { + grid-column: span 2 / span 2; +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-8 { + margin-top: 2rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.ml-\[-100\%\] { + margin-left: -100%; +} + +.-mt-5 { + margin-top: -1.25rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.inline { + display: inline; +} + +.flex { + display: flex; +} + +.table { + display: table; +} + +.grid { + display: grid; +} + +.contents { + display: contents; +} + +.hidden { + display: none; +} + +.\!hidden { + display: none !important; +} + +.h-\[50vh\] { + height: 50vh; +} + +.h-full { + height: 100%; +} + +.h-4 { + height: 1rem; +} + +.h-px { + height: 1px; +} + +.h-12 { + height: 3rem; +} + +.h-24 { + height: 6rem; +} + +.h-2 { + height: 0.5rem; +} + +.h-\[0vh\] { + height: 0vh; +} + +.h-screen { + height: 100vh; +} + +.h-5 { + height: 1.25rem; +} + +.h-\[95\%\] { + height: 95%; +} + +.h-8 { + height: 2rem; +} + +.h-6 { + height: 1.5rem; +} + +.h-20 { + height: 5rem; +} + +.max-h-60 { + max-height: 15rem; +} + +.max-h-\[800px\] { + max-height: 800px; +} + +.min-h-screen { + min-height: 100vh; +} + +.min-h-0 { + min-height: 0px; +} + +.w-full { + width: 100%; +} + +.w-\[300px\] { + width: 300px; +} + +.w-5 { + width: 1.25rem; +} + +.w-fit { + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; +} + +.w-\[95\%\] { + width: 95%; +} + +.w-\[250px\] { + width: 250px; +} + +.w-\[40px\] { + width: 40px; +} + +.w-\[230px\] { + width: 230px; +} + +.w-screen { + width: 100vw; +} + +.w-\[max-content\] { + width: -webkit-max-content; + width: -moz-max-content; + width: max-content; +} + +.w-\[500px\] { + width: 500px; +} + +.w-\[600px\] { + width: 600px; +} + +.w-\[450px\] { + width: 450px; +} + +.min-w-\[250px\] { + min-width: 250px; +} + +.min-w-0 { + min-width: 0px; +} + +.max-w-full { + max-width: 100%; +} + +.max-w-none { + max-width: none; +} + +.max-w-\[350px\] { + max-width: 350px; +} + +.max-w-screen-lg { + max-width: 1024px; +} + +.max-w-md { + max-width: 28rem; +} + +.max-w-sm { + max-width: 24rem; +} + +.max-w-screen-sm { + max-width: 640px; +} + +.max-w-screen-xl { + max-width: 1280px; +} + +.max-w-\[600px\] { + max-width: 600px; +} + +.max-w-\[500px\] { + max-width: 500px; +} + +.max-w-\[1200px\] { + max-width: 1200px; +} + +.max-w-3xl { + max-width: 48rem; +} + +.max-w-\[400px\] { + max-width: 400px; +} + +.max-w-\[300px\] { + max-width: 300px; +} + +.flex-1 { + flex: 1 1 0%; +} + +.flex-\[3_1_0\%\] { + flex: 3 1 0%; +} + +.flex-\[2_1_0\%\] { + flex: 2 1 0%; +} + +.origin-bottom-right { + transform-origin: bottom right; +} + +.origin-top { + transform-origin: top; +} + +.-translate-y-1\/2 { + --tw-translate-y: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-y-\[50px\] { + --tw-translate-y: -50px; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-1\/2 { + --tw-translate-x: -50%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-x-full { + --tw-translate-x: -100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-full { + --tw-translate-x: 100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-y-full { + --tw-translate-y: -100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-y-full { + --tw-translate-y: 100%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-translate-y-1\/3 { + --tw-translate-y: -33.333333%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.translate-x-1\/3 { + --tw-translate-x: 33.333333%; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.-rotate-90 { + --tw-rotate: -90deg; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-\[2\] { + --tw-scale-x: 2; + --tw-scale-y: 2; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.scale-125 { + --tw-scale-x: 1.25; + --tw-scale-y: 1.25; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +.transform { + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) + rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) + scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + +@-webkit-keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.animate-spin { + -webkit-animation: spin 1s linear infinite; + animation: spin 1s linear infinite; +} + +@-webkit-keyframes pulse { + 50% { + opacity: 0.5; + } +} + +@keyframes pulse { + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + -webkit-animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@-webkit-keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + +@keyframes bounce { + 0%, + 100% { + transform: translateY(-25%); + -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: none; + -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + +.animate-bounce { + -webkit-animation: bounce 1s infinite; + animation: bounce 1s infinite; +} + +.cursor-pointer { + cursor: pointer; +} + +.cursor-default { + cursor: default; +} + +.select-none { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.resize { + resize: both; +} + +.grid-flow-row { + grid-auto-flow: row; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-start { + align-items: flex-start; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-6 { + gap: 1.5rem; +} + +.gap-2 { + gap: 0.5rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-1 { + gap: 0.25rem; +} + +.gap-8 { + gap: 2rem; +} + +.gap-12 { + gap: 3rem; +} + +.gap-20 { + gap: 5rem; +} + +.gap-x-10 { + -moz-column-gap: 2.5rem; + column-gap: 2.5rem; +} + +.gap-y-4 { + row-gap: 1rem; +} + +.space-y-px > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1px * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1px * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + +.space-y-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.divide-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-y-reverse: 0; + border-top-width: calc(2px * calc(1 - var(--tw-divide-y-reverse))); + border-bottom-width: calc(2px * var(--tw-divide-y-reverse)); +} + +.divide-gray-500 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-divide-opacity)); +} + +.divide-opacity-10 > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 0.1; +} + +.self-start { + align-self: flex-start; +} + +.self-end { + align-self: flex-end; +} + +.self-center { + align-self: center; +} + +.overflow-auto { + overflow: auto; +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-scroll { + overflow: scroll; +} + +.overflow-y-auto { + overflow-y: auto; +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-md { + border-radius: 0.375rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.rounded-xl { + border-radius: 0.75rem; +} + +.rounded-t-md { + border-top-left-radius: 0.375rem; + border-top-right-radius: 0.375rem; +} + +.rounded-t-lg { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +.border-4 { + border-width: 4px; +} + +.border-b { + border-bottom-width: 1px; +} + +.border-t { + border-top-width: 1px; +} + +.border-t-4 { + border-top-width: 4px; +} + +.border-none { + border-style: none; +} + +.border-black\/50 { + border-color: rgb(0 0 0 / 0.5); +} + +.border-gray-300 { + --tw-border-opacity: 1; + border-color: rgb(209 213 219 / var(--tw-border-opacity)); +} + +.border-gray-200 { + --tw-border-opacity: 1; + border-color: rgb(229 231 235 / var(--tw-border-opacity)); +} + +.border-gray-500 { + --tw-border-opacity: 1; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); +} + +.border-black\/10 { + border-color: rgb(0 0 0 / 0.1); +} + +.border-gray-100 { + --tw-border-opacity: 1; + border-color: rgb(243 244 246 / var(--tw-border-opacity)); +} + +.border-gray-600\/70 { + border-color: rgb(75 85 99 / 0.7); +} + +.border-transparent { + border-color: transparent; +} + +.border-red-500 { + --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)); +} + +.border-green-500 { + --tw-border-opacity: 1; + border-color: rgb(34 197 94 / var(--tw-border-opacity)); +} + +.border-opacity-20 { + --tw-border-opacity: 0.2; +} + +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + +.bg-transparent { + background-color: transparent; +} + +.bg-rose-600 { + --tw-bg-opacity: 1; + background-color: rgb(225 29 72 / var(--tw-bg-opacity)); +} + +.bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.bg-gray-500 { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + +.bg-blue-500 { + --tw-bg-opacity: 1; + background-color: rgb(59 130 246 / var(--tw-bg-opacity)); +} + +.bg-orange-500 { + --tw-bg-opacity: 1; + background-color: rgb(249 115 22 / var(--tw-bg-opacity)); +} + +.bg-green-500 { + --tw-bg-opacity: 1; + background-color: rgb(34 197 94 / var(--tw-bg-opacity)); +} + +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); +} + +.bg-emerald-500 { + --tw-bg-opacity: 1; + background-color: rgb(16 185 129 / var(--tw-bg-opacity)); +} + +.bg-purple-500 { + --tw-bg-opacity: 1; + background-color: rgb(168 85 247 / var(--tw-bg-opacity)); +} + +.bg-yellow-500 { + --tw-bg-opacity: 1; + background-color: rgb(234 179 8 / var(--tw-bg-opacity)); +} + +.bg-pink-500 { + --tw-bg-opacity: 1; + background-color: rgb(236 72 153 / var(--tw-bg-opacity)); +} + +.bg-amber-500 { + --tw-bg-opacity: 1; + background-color: rgb(245 158 11 / var(--tw-bg-opacity)); +} + +.bg-lime-500 { + --tw-bg-opacity: 1; + background-color: rgb(132 204 22 / var(--tw-bg-opacity)); +} + +.bg-slate-700 { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); +} + +.bg-discord { + --tw-bg-opacity: 1; + background-color: rgb(83 107 189 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.bg-\[\#ED203D\] { + --tw-bg-opacity: 1; + background-color: rgb(237 32 61 / var(--tw-bg-opacity)); +} + +.bg-red-500 { + --tw-bg-opacity: 1; + background-color: rgb(239 68 68 / var(--tw-bg-opacity)); +} + +.bg-yellow-400 { + --tw-bg-opacity: 1; + background-color: rgb(250 204 21 / var(--tw-bg-opacity)); +} + +.bg-gray-300 { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); +} + +.bg-teal-500 { + --tw-bg-opacity: 1; + background-color: rgb(20 184 166 / var(--tw-bg-opacity)); +} + +.bg-red-400 { + --tw-bg-opacity: 1; + background-color: rgb(248 113 113 / var(--tw-bg-opacity)); +} + +.bg-rose-500 { + --tw-bg-opacity: 1; + background-color: rgb(244 63 94 / var(--tw-bg-opacity)); +} + +.bg-opacity-10 { + --tw-bg-opacity: 0.1; +} + +.bg-gradient-to-r { + background-image: linear-gradient(to right, var(--tw-gradient-stops)); +} + +.bg-\[linear-gradient\(to_right\2c \#59b8ff\2c \#e331d8\2c \#ff9600\2c red\)\] { + background-image: linear-gradient(to right, #59b8ff, #e331d8, #ff9600, red); +} + +.bg-\[linear-gradient\(to_right\2c \#59b8ff\2c \#e331d8\2c \#ff9600\)\] { + background-image: linear-gradient(to right, #59b8ff, #e331d8, #ff9600); +} + +.from-red-500 { + --tw-gradient-from: #ef4444; + --tw-gradient-to: rgb(239 68 68 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-rose-500 { + --tw-gradient-from: #f43f5e; + --tw-gradient-to: rgb(244 63 94 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-yellow-500 { + --tw-gradient-from: #eab308; + --tw-gradient-to: rgb(234 179 8 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-teal-500 { + --tw-gradient-from: #14b8a6; + --tw-gradient-to: rgb(20 184 166 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-blue-500 { + --tw-gradient-from: #3b82f6; + --tw-gradient-to: rgb(59 130 246 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.from-lime-500 { + --tw-gradient-from: #84cc16; + --tw-gradient-to: rgb(132 204 22 / 0); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); +} + +.via-purple-500 { + --tw-gradient-to: rgb(168 85 247 / 0); + --tw-gradient-stops: var(--tw-gradient-from), #a855f7, var(--tw-gradient-to); +} + +.to-amber-500 { + --tw-gradient-to: #f59e0b; +} + +.to-yellow-500 { + --tw-gradient-to: #eab308; +} + +.to-violet-600 { + --tw-gradient-to: #7c3aed; +} + +.to-yellow-600 { + --tw-gradient-to: #ca8a04; +} + +.to-teal-500 { + --tw-gradient-to: #14b8a6; +} + +.to-violet-500 { + --tw-gradient-to: #8b5cf6; +} + +.to-pink-500 { + --tw-gradient-to: #ec4899; +} + +.to-red-700 { + --tw-gradient-to: #b91c1c; +} + +.to-emerald-500 { + --tw-gradient-to: #10b981; +} + +.to-blue-500 { + --tw-gradient-to: #3b82f6; +} + +.to-blue-600 { + --tw-gradient-to: #2563eb; +} + +.bg-contain { + background-size: contain; +} + +.bg-clip-text { + -webkit-background-clip: text; + background-clip: text; +} + +.bg-center { + background-position: center; +} + +.bg-no-repeat { + background-repeat: no-repeat; +} + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.p-1 { + padding: 0.25rem; +} + +.p-8 { + padding: 2rem; +} + +.p-3 { + padding: 0.75rem; +} + +.p-12 { + padding: 3rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-12 { + padding-top: 3rem; + padding-bottom: 3rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pb-\[300px\] { + padding-bottom: 300px; +} + +.pl-2 { + padding-left: 0.5rem; +} + +.pr-10 { + padding-right: 2.5rem; +} + +.pr-2 { + padding-right: 0.5rem; +} + +.pl-10 { + padding-left: 2.5rem; +} + +.pr-3 { + padding-right: 0.75rem; +} + +.pb-16 { + padding-bottom: 4rem; +} + +.text-left { + text-align: left; +} + +.text-center { + text-align: center; +} + +.align-super { + vertical-align: super; +} + +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-xs { + font-size: 0.75rem; + line-height: 1rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-7xl { + font-size: 4.5rem; + line-height: 1; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.text-\[\.9em\] { + font-size: 0.9em; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-\[\.7rem\] { + font-size: 0.7rem; +} + +.text-5xl { + font-size: 3rem; + line-height: 1; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + +.text-\[\.5em\] { + font-size: 0.5em; +} + +.text-6xl { + font-size: 3.75rem; + line-height: 1; +} + +.font-normal { + font-weight: 400; +} + +.font-black { + font-weight: 900; +} + +.font-extrabold { + font-weight: 800; +} + +.font-bold { + font-weight: 700; +} + +.font-medium { + font-weight: 500; +} + +.font-light { + font-weight: 300; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.capitalize { + text-transform: capitalize; +} + +.italic { + font-style: italic; +} + +.leading-none { + line-height: 1; +} + +.leading-6 { + line-height: 1.5rem; +} + +.leading-tight { + line-height: 1.25; +} + +.leading-7 { + line-height: 1.75rem; +} + +.leading-8 { + line-height: 2rem; +} + +.leading-loose { + line-height: 2; +} + +.tracking-tight { + letter-spacing: -0.025em; +} + +.tracking-wider { + letter-spacing: 0.05em; +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.text-red-600 { + --tw-text-opacity: 1; + color: rgb(220 38 38 / var(--tw-text-opacity)); +} + +.text-transparent { + color: transparent; +} + +.text-sky-500 { + --tw-text-opacity: 1; + color: rgb(14 165 233 / var(--tw-text-opacity)); +} + +.text-gray-500 { + --tw-text-opacity: 1; + color: rgb(107 114 128 / var(--tw-text-opacity)); +} + +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.text-gray-900 { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.text-gray-800 { + --tw-text-opacity: 1; + color: rgb(31 41 55 / var(--tw-text-opacity)); +} + +.text-rose-500 { + --tw-text-opacity: 1; + color: rgb(244 63 94 / var(--tw-text-opacity)); +} + +.text-yellow-500 { + --tw-text-opacity: 1; + color: rgb(234 179 8 / var(--tw-text-opacity)); +} + +.text-teal-500 { + --tw-text-opacity: 1; + color: rgb(20 184 166 / var(--tw-text-opacity)); +} + +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.text-emerald-500 { + --tw-text-opacity: 1; + color: rgb(16 185 129 / var(--tw-text-opacity)); +} + +.text-purple-500 { + --tw-text-opacity: 1; + color: rgb(168 85 247 / var(--tw-text-opacity)); +} + +.text-orange-500 { + --tw-text-opacity: 1; + color: rgb(249 115 22 / var(--tw-text-opacity)); +} + +.text-pink-500 { + --tw-text-opacity: 1; + color: rgb(236 72 153 / var(--tw-text-opacity)); +} + +.text-amber-500 { + --tw-text-opacity: 1; + color: rgb(245 158 11 / var(--tw-text-opacity)); +} + +.text-lime-500 { + --tw-text-opacity: 1; + color: rgb(132 204 22 / var(--tw-text-opacity)); +} + +.text-slate-600 { + --tw-text-opacity: 1; + color: rgb(71 85 105 / var(--tw-text-opacity)); +} + +.text-green-500 { + --tw-text-opacity: 1; + color: rgb(34 197 94 / var(--tw-text-opacity)); +} + +.text-discord { + --tw-text-opacity: 1; + color: rgb(83 107 189 / var(--tw-text-opacity)); +} + +.text-green-600 { + --tw-text-opacity: 1; + color: rgb(22 163 74 / var(--tw-text-opacity)); +} + +.text-violet-500 { + --tw-text-opacity: 1; + color: rgb(139 92 246 / var(--tw-text-opacity)); +} + +.text-yellow-400 { + --tw-text-opacity: 1; + color: rgb(250 204 21 / var(--tw-text-opacity)); +} + +.text-yellow-600 { + --tw-text-opacity: 1; + color: rgb(202 138 4 / var(--tw-text-opacity)); +} + +.text-amber-600 { + --tw-text-opacity: 1; + color: rgb(217 119 6 / var(--tw-text-opacity)); +} + +.text-amber-700 { + --tw-text-opacity: 1; + color: rgb(180 83 9 / var(--tw-text-opacity)); +} + +.text-red-700 { + --tw-text-opacity: 1; + color: rgb(185 28 28 / var(--tw-text-opacity)); +} + +.text-orange-600 { + --tw-text-opacity: 1; + color: rgb(234 88 12 / var(--tw-text-opacity)); +} + +.text-orange-700 { + --tw-text-opacity: 1; + color: rgb(194 65 12 / var(--tw-text-opacity)); +} + +.text-lime-600 { + --tw-text-opacity: 1; + color: rgb(101 163 13 / var(--tw-text-opacity)); +} + +.text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); +} + +.text-teal-700 { + --tw-text-opacity: 1; + color: rgb(15 118 110 / var(--tw-text-opacity)); +} + +.text-blue-600 { + --tw-text-opacity: 1; + color: rgb(37 99 235 / var(--tw-text-opacity)); +} + +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + +.text-indigo-500 { + --tw-text-opacity: 1; + color: rgb(99 102 241 / var(--tw-text-opacity)); +} + +.text-indigo-700 { + --tw-text-opacity: 1; + color: rgb(67 56 202 / var(--tw-text-opacity)); +} + +.text-rose-600 { + --tw-text-opacity: 1; + color: rgb(225 29 72 / var(--tw-text-opacity)); +} + +.text-pink-700 { + --tw-text-opacity: 1; + color: rgb(190 24 93 / var(--tw-text-opacity)); +} + +.text-violet-700 { + --tw-text-opacity: 1; + color: rgb(109 40 217 / var(--tw-text-opacity)); +} + +.underline { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; +} + +.decoration-gray-200 { + -webkit-text-decoration-color: #e5e7eb; + text-decoration-color: #e5e7eb; +} + +.decoration-yellow-500 { + -webkit-text-decoration-color: #eab308; + text-decoration-color: #eab308; +} + +.decoration-red-500 { + -webkit-text-decoration-color: #ef4444; + text-decoration-color: #ef4444; +} + +.decoration-dashed { + -webkit-text-decoration-style: dashed; + text-decoration-style: dashed; +} + +.decoration-4 { + text-decoration-thickness: 4px; +} + +.underline-offset-\[\.5rem\] { + text-underline-offset: 0.5rem; +} + +.underline-offset-2 { + text-underline-offset: 2px; +} + +.placeholder-gray-400::-moz-placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity)); +} + +.placeholder-gray-400:-ms-input-placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity)); +} + +.placeholder-gray-400::placeholder { + --tw-placeholder-opacity: 1; + color: rgb(156 163 175 / var(--tw-placeholder-opacity)); +} + +.opacity-0 { + opacity: 0; +} + +.opacity-10 { + opacity: 0.1; +} + +.opacity-20 { + opacity: 0.2; +} + +.opacity-30 { + opacity: 0.3; +} + +.opacity-70 { + opacity: 0.7; +} + +.opacity-100 { + opacity: 1; +} + +.opacity-40 { + opacity: 0.4; +} + +.opacity-90 { + opacity: 0.9; +} + +.opacity-50 { + opacity: 0.5; +} + +.opacity-60 { + opacity: 0.6; +} + +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), + 0 4px 6px -2px rgba(0, 0, 0, 0.03); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), + 0 4px 6px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-md { + --tw-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), + 0 2px 4px -1px rgba(0, 0, 0, 0.03); + --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), + 0 2px 4px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-xl { + --tw-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), + 0 10px 10px -5px rgba(0, 0, 0, 0.02); + --tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), + 0 10px 10px -5px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px 0 rgba(0, 0, 0, 0.03); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), + 0 1px 2px 0 var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-2xl { + --tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.2); + --tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.shadow-black\/10 { + --tw-shadow-color: rgb(0 0 0 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-red-700\/20 { + --tw-shadow-color: rgb(185 28 28 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-blue-700\/20 { + --tw-shadow-color: rgb(29 78 216 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-emerald-700\/20 { + --tw-shadow-color: rgb(4 120 87 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-amber-700\/20 { + --tw-shadow-color: rgb(180 83 9 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-purple-700\/20 { + --tw-shadow-color: rgb(126 34 206 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-yellow-700\/20 { + --tw-shadow-color: rgb(161 98 7 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-orange-700\/20 { + --tw-shadow-color: rgb(194 65 12 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-pink-700\/20 { + --tw-shadow-color: rgb(190 24 93 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-lime-700\/20 { + --tw-shadow-color: rgb(77 124 15 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-slate-700\/20 { + --tw-shadow-color: rgb(51 65 85 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-gray-500\/20 { + --tw-shadow-color: rgb(107 114 128 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-green-700\/10 { + --tw-shadow-color: rgb(21 128 61 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-indigo-700\/30 { + --tw-shadow-color: rgb(67 56 202 / 0.3); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-gray-900\/10 { + --tw-shadow-color: rgb(17 24 39 / 0.1); + --tw-shadow: var(--tw-shadow-colored); +} + +.shadow-gray-700\/20 { + --tw-shadow-color: rgb(55 65 81 / 0.2); + --tw-shadow: var(--tw-shadow-colored); +} + +.outline-none { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.ring-1 { + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 + var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 + calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), + var(--tw-shadow, 0 0 #0000); +} + +.ring-black { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(0 0 0 / var(--tw-ring-opacity)); +} + +.ring-opacity-5 { + --tw-ring-opacity: 0.05; +} + +.filter { + filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) + var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) + var(--tw-sepia) var(--tw-drop-shadow); +} + +.transition { + transition-property: color, background-color, border-color, fill, stroke, + opacity, box-shadow, transform, filter, -webkit-text-decoration-color, + -webkit-backdrop-filter; + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + backdrop-filter; + transition-property: color, background-color, border-color, + text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, + backdrop-filter, -webkit-text-decoration-color, -webkit-backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-opacity { + transition-property: opacity; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.transition-all { + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.delay-300 { + transition-delay: 300ms; +} + +.duration-300 { + transition-duration: 300ms; +} + +.duration-100 { + transition-duration: 100ms; +} + +.ease-in { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + +.ease-in-out { + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +.carbon-small { + pointer-events: none; +} + +.carbon-small #carbonads { + pointer-events: none; +} + +.carbon-small .carbon-outer { + pointer-events: none; +} + +.carbon-small .carbon-wrap { + display: flex; + flex-direction: column; +} + +.carbon-small .carbon-wrap .carbon-img { + pointer-events: auto !important; + width: 50%; + overflow: hidden; + border-top-right-radius: 0.5rem; + border-top-width: 1px; + border-right-width: 1px; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); + --tw-border-opacity: 0.1; + padding-top: 0.5rem; +} + +.carbon-small .carbon-wrap .carbon-img img { + width: 100%; + max-width: 100% !important; +} + +.carbon-small .carbon-wrap .carbon-text { + pointer-events: auto !important; + margin: 0px !important; + border-top-right-radius: 0.5rem; + border-top-width: 1px; + border-right-width: 1px; + border-color: rgb(107 114 128 / var(--tw-border-opacity)); + --tw-border-opacity: 0.1; + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); + padding-bottom: 1.5rem !important; +} + +@media (prefers-color-scheme: dark) { + .carbon-small .carbon-wrap .carbon-text { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); + } +} + +.carbon-small .carbon-wrap .carbon-poweredby { + position: absolute; + bottom: 0px; + right: 0px; +} + +code[class*='language-'] { + white-space: pre-wrap; + word-break: break-all; +} + +.even\:opacity-40:nth-child(even) { + opacity: 0.4; +} + +.hover\:border-current:hover { + border-color: currentColor; +} + +.hover\:border-green-500:hover { + --tw-border-opacity: 1; + border-color: rgb(34 197 94 / var(--tw-border-opacity)); +} + +.hover\:border-blue-500:hover { + --tw-border-opacity: 1; + border-color: rgb(59 130 246 / var(--tw-border-opacity)); +} + +.hover\:bg-gray-500:hover { + --tw-bg-opacity: 1; + background-color: rgb(107 114 128 / var(--tw-bg-opacity)); +} + +.hover\:bg-rose-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(225 29 72 / var(--tw-bg-opacity)); +} + +.hover\:bg-gray-100\/70:hover { + background-color: rgb(243 244 246 / 0.7); +} + +.hover\:bg-yellow-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(250 204 21 / var(--tw-bg-opacity)); +} + +.hover\:bg-red-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(252 165 165 / var(--tw-bg-opacity)); +} + +.hover\:bg-teal-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(94 234 212 / var(--tw-bg-opacity)); +} + +.hover\:bg-red-400:hover { + --tw-bg-opacity: 1; + background-color: rgb(248 113 113 / var(--tw-bg-opacity)); +} + +.hover\:bg-rose-300:hover { + --tw-bg-opacity: 1; + background-color: rgb(253 164 175 / var(--tw-bg-opacity)); +} + +.hover\:bg-opacity-10:hover { + --tw-bg-opacity: 0.1; +} + +.hover\:text-red-500:hover { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +.hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.hover\:underline:hover { + -webkit-text-decoration-line: underline; + text-decoration-line: underline; +} + +.hover\:opacity-0:hover { + opacity: 0; +} + +.hover\:opacity-100:hover { + opacity: 1; +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +.focus-visible\:border-indigo-500:focus-visible { + --tw-border-opacity: 1; + border-color: rgb(99 102 241 / var(--tw-border-opacity)); +} + +.group:hover .group-hover\:opacity-100 { + opacity: 1; +} + +@media (prefers-color-scheme: dark) { + .dark\:prose-invert { + --tw-prose-body: var(--tw-prose-invert-body); + --tw-prose-headings: var(--tw-prose-invert-headings); + --tw-prose-lead: var(--tw-prose-invert-lead); + --tw-prose-links: var(--tw-prose-invert-links); + --tw-prose-bold: var(--tw-prose-invert-bold); + --tw-prose-counters: var(--tw-prose-invert-counters); + --tw-prose-bullets: var(--tw-prose-invert-bullets); + --tw-prose-hr: var(--tw-prose-invert-hr); + --tw-prose-quotes: var(--tw-prose-invert-quotes); + --tw-prose-quote-borders: var(--tw-prose-invert-quote-borders); + --tw-prose-captions: var(--tw-prose-invert-captions); + --tw-prose-code: var(--tw-prose-invert-code); + --tw-prose-pre-code: var(--tw-prose-invert-pre-code); + --tw-prose-pre-bg: var(--tw-prose-invert-pre-bg); + --tw-prose-th-borders: var(--tw-prose-invert-th-borders); + --tw-prose-td-borders: var(--tw-prose-invert-td-borders); + } + + .dark\:h-\[100\.5\%\] { + height: 100.5%; + } + + .dark\:w-\[100\.5\%\] { + width: 100.5%; + } + + .dark\:border-0 { + border-width: 0px; + } + + .dark\:border { + border-width: 1px; + } + + .dark\:border-2 { + border-width: 2px; + } + + .dark\:border-white\/50 { + border-color: rgb(255 255 255 / 0.5); + } + + .dark\:border-gray-800 { + --tw-border-opacity: 1; + border-color: rgb(31 41 55 / var(--tw-border-opacity)); + } + + .dark\:border-white\/10 { + border-color: rgb(255 255 255 / 0.1); + } + + .dark\:border-gray-700\/80 { + border-color: rgb(55 65 81 / 0.8); + } + + .dark\:bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-600 { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-700 { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); + } + + .dark\:bg-black { + --tw-bg-opacity: 1; + background-color: rgb(0 0 0 / var(--tw-bg-opacity)); + } + + .dark\:bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); + } + + .dark\:bg-opacity-20 { + --tw-bg-opacity: 0.2; + } + + .dark\:text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); + } + + .dark\:text-gray-300 { + --tw-text-opacity: 1; + color: rgb(209 213 219 / var(--tw-text-opacity)); + } + + .dark\:text-emerald-400 { + --tw-text-opacity: 1; + color: rgb(52 211 153 / var(--tw-text-opacity)); + } + + .dark\:text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); + } + + .dark\:text-gray-200 { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); + } + + .dark\:text-yellow-300 { + --tw-text-opacity: 1; + color: rgb(253 224 71 / var(--tw-text-opacity)); + } + + .dark\:text-yellow-500 { + --tw-text-opacity: 1; + color: rgb(234 179 8 / var(--tw-text-opacity)); + } + + .dark\:text-amber-500 { + --tw-text-opacity: 1; + color: rgb(245 158 11 / var(--tw-text-opacity)); + } + + .dark\:text-red-400 { + --tw-text-opacity: 1; + color: rgb(248 113 113 / var(--tw-text-opacity)); + } + + .dark\:text-orange-400 { + --tw-text-opacity: 1; + color: rgb(251 146 60 / var(--tw-text-opacity)); + } + + .dark\:text-amber-400 { + --tw-text-opacity: 1; + color: rgb(251 191 36 / var(--tw-text-opacity)); + } + + .dark\:text-lime-400 { + --tw-text-opacity: 1; + color: rgb(163 230 53 / var(--tw-text-opacity)); + } + + .dark\:text-teal-400 { + --tw-text-opacity: 1; + color: rgb(45 212 191 / var(--tw-text-opacity)); + } + + .dark\:text-red-300 { + --tw-text-opacity: 1; + color: rgb(252 165 165 / var(--tw-text-opacity)); + } + + .dark\:text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); + } + + .dark\:text-blue-400 { + --tw-text-opacity: 1; + color: rgb(96 165 250 / var(--tw-text-opacity)); + } + + .dark\:text-indigo-400 { + --tw-text-opacity: 1; + color: rgb(129 140 248 / var(--tw-text-opacity)); + } + + .dark\:text-rose-400 { + --tw-text-opacity: 1; + color: rgb(251 113 133 / var(--tw-text-opacity)); + } + + .dark\:text-pink-400 { + --tw-text-opacity: 1; + color: rgb(244 114 182 / var(--tw-text-opacity)); + } + + .dark\:text-violet-400 { + --tw-text-opacity: 1; + color: rgb(167 139 250 / var(--tw-text-opacity)); + } + + .dark\:decoration-gray-800 { + -webkit-text-decoration-color: #1f2937; + text-decoration-color: #1f2937; + } + + .dark\:shadow-lg { + --tw-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.05), + 0 4px 6px -2px rgba(0, 0, 0, 0.03); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), + 0 4px 6px -2px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + .dark\:shadow-none { + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + .dark\:shadow-red-500\/30 { + --tw-shadow-color: rgb(239 68 68 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-blue-500\/30 { + --tw-shadow-color: rgb(59 130 246 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-emerald-500\/30 { + --tw-shadow-color: rgb(16 185 129 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-amber-500\/30 { + --tw-shadow-color: rgb(245 158 11 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-purple-500\/30 { + --tw-shadow-color: rgb(168 85 247 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-yellow-500\/30 { + --tw-shadow-color: rgb(234 179 8 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-orange-500\/30 { + --tw-shadow-color: rgb(249 115 22 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-pink-500\/30 { + --tw-shadow-color: rgb(236 72 153 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-lime-500\/30 { + --tw-shadow-color: rgb(132 204 22 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-slate-500\/30 { + --tw-shadow-color: rgb(100 116 139 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:shadow-green-500\/30 { + --tw-shadow-color: rgb(34 197 94 / 0.3); + --tw-shadow: var(--tw-shadow-colored); + } + + .dark\:hover\:bg-gray-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); + } +} + +@media (min-width: 640px) { + .sm\:col-span-2 { + grid-column: span 2 / span 2; + } + + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sm\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .sm\:gap-4 { + gap: 1rem; + } + + .sm\:p-8 { + padding: 2rem; + } + + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .sm\:text-center { + text-align: center; + } + + .sm\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .sm\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .sm\:leading-10 { + line-height: 2.5rem; + } + + .sm\:opacity-20 { + opacity: 0.2; + } +} + +@media (min-width: 768px) { + .md\:col-span-5 { + grid-column: span 5 / span 5; + } + + .md\:mx-auto { + margin-left: auto; + margin-right: auto; + } + + .md\:mb-2 { + margin-bottom: 0.5rem; + } + + .md\:w-\[60px\] { + width: 60px; + } + + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .md\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:justify-end { + justify-content: flex-end; + } + + .md\:gap-32 { + gap: 8rem; + } + + .md\:space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); + } + + .md\:self-end { + align-self: flex-end; + } + + .md\:p-6 { + padding: 1.5rem; + } + + .md\:p-8 { + padding: 2rem; + } + + .md\:p-14 { + padding: 3.5rem; + } + + .md\:text-right { + text-align: right; + } + + .md\:text-base { + font-size: 1rem; + line-height: 1.5rem; + } + + .md\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } + + .md\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .md\:text-\[\.9em\] { + font-size: 0.9em; + } + + .md\:text-6xl { + font-size: 3.75rem; + line-height: 1; + } + + .md\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .md\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } + + .md\:decoration-8 { + text-decoration-thickness: 8px; + } + + .md\:underline-offset-\[1rem\] { + text-underline-offset: 1rem; + } +} + +@media (min-width: 1024px) { + .lg\:mt-2 { + margin-top: 0.5rem; + } + + .lg\:flex { + display: flex; + } + + .lg\:hidden { + display: none; + } + + .lg\:h-16 { + height: 4rem; + } + + .lg\:w-\[100px\] { + width: 100px; + } + + .lg\:max-w-2xl { + max-width: 42rem; + } + + .lg\:max-w-screen-lg { + max-width: 1024px; + } + + .lg\:max-w-\[800px\] { + max-width: 800px; + } + + .lg\:max-w-\[600px\] { + max-width: 600px; + } + + .lg\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .lg\:flex-row { + flex-direction: row; + } + + .lg\:gap-4 { + gap: 1rem; + } + + .lg\:rounded-lg { + border-radius: 0.5rem; + } + + .lg\:p-6 { + padding: 1.5rem; + } + + .lg\:py-24 { + padding-top: 6rem; + padding-bottom: 6rem; + } + + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } + + .lg\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .lg\:pl-\[250px\] { + padding-left: 250px; + } + + .lg\:text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; + } + + .lg\:text-lg { + font-size: 1.125rem; + line-height: 1.75rem; + } + + .lg\:text-8xl { + font-size: 6rem; + line-height: 1; + } + + .lg\:text-5xl { + font-size: 3rem; + line-height: 1; + } + + .lg\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; + } + + .lg\:text-7xl { + font-size: 4.5rem; + line-height: 1; + } + + .lg\:text-2xl { + font-size: 1.5rem; + line-height: 2rem; + } + + .lg\:leading-none { + line-height: 1; + } +} + +@media (min-width: 1280px) { + .xl\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .xl\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } +} diff --git a/app/styles/carbon.css b/app/styles/carbon.css new file mode 100644 index 000000000..8c7d74964 --- /dev/null +++ b/app/styles/carbon.css @@ -0,0 +1,59 @@ +#carbonads_1 { + display: none; +} + +#carbonads * { + margin: initial; + padding: initial; +} +#carbonads { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Helvetica, Arial, + sans-serif; +} +#carbonads { + display: flex; +} +#carbonads a { + text-decoration: none; +} +#carbonads a:hover { + color: inherit; +} +#carbonads span { + position: relative; + display: block; + overflow: hidden; +} +#carbonads .carbon-wrap { + display: flex; +} +#carbonads .carbon-img { + display: block; + margin: 0; + line-height: 1; +} +#carbonads .carbon-img img { + display: block; +} +#carbonads .carbon-text { + font-size: 13px; + padding: 10px; + margin-bottom: 16px; + line-height: 1.5; + text-align: left; +} +#carbonads .carbon-poweredby { + display: block; + padding: 6px 8px; + text-align: center; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + font-size: 8px; + line-height: 1; + border-top-left-radius: 3px; + position: absolute; + bottom: 0; + right: 0; +} diff --git a/app/utils/blog.ts b/app/utils/blog.ts new file mode 100644 index 000000000..5586734c4 --- /dev/null +++ b/app/utils/blog.ts @@ -0,0 +1,13 @@ +export function getPostList() { + return [ + { + id: 'announcing-tanstack-query-v5', + }, + { + id: 'announcing-tanstack-query-v4', + }, + { + id: 'ag-grid-partnership', + }, + ] +} diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts new file mode 100644 index 000000000..e82f849e6 --- /dev/null +++ b/app/utils/cache.server.ts @@ -0,0 +1,31 @@ +import LRUCache from 'lru-cache' + +declare global { + var docCache: LRUCache +} + +let docCache = + globalThis.docCache || + (globalThis.docCache = new LRUCache({ + max: 300, + // ttl: 1, + ttl: process.env.NODE_ENV === 'production' ? 1 : 1000000, + })) + +export async function fetchCached(opts: { + fn: () => Promise + key: string + ttl: number +}): Promise { + if (docCache.has(opts.key)) { + return docCache.get(opts.key) as T + } + + const result = await opts.fn() + + docCache.set(opts.key, result, { + ttl: opts.ttl, + }) + + return result +} diff --git a/app/utils/config.ts b/app/utils/config.ts new file mode 100644 index 000000000..c0952d28f --- /dev/null +++ b/app/utils/config.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { fetchRepoFile } from './documents.server' +import { createServerFn } from '@tanstack/start' + +export type MenuItem = { + label: string | React.ReactNode + children: { + label: string | React.ReactNode + to: string + badge?: string + }[] +} + +const configSchema = z.object({ + sections: z.array( + z.object({ + label: z.string(), + children: z.array( + z.object({ + label: z.string(), + to: z.string(), + badge: z.string().optional(), + }) + ), + frameworks: z + .array( + z.object({ + label: z.string(), + children: z.array( + z.object({ + label: z.string(), + to: z.string(), + badge: z.string().optional(), + }) + ), + }) + ) + .optional(), + }) + ), + users: z.array(z.string()).optional(), +}) + +export type ConfigSchema = z.infer + +/** + Fetch the config file for the project and validate it. + */ +export const getTanstackDocsConfig = createServerFn( + 'GET', + async ({ repo, branch }: { repo: string; branch: string }) => { + 'use server' + + const config = await fetchRepoFile(repo, branch, `docs/config.json`) + + if (!config) { + throw new Error('Repo docs/config.json not found!') + } + + try { + const tanstackDocsConfigFromJson = JSON.parse(config) + const validationResult = configSchema.safeParse( + tanstackDocsConfigFromJson + ) + + if (!validationResult.success) { + // Log the issues that come up during validation + console.error(JSON.stringify(validationResult.error, null, 2)) + throw new Error('Zod validation failed') + } + + return validationResult.data + } catch (e) { + throw new Error('Invalid docs/config.json file') + } + } +) diff --git a/app/utils/docs.ts b/app/utils/docs.ts new file mode 100644 index 000000000..cd61050f1 --- /dev/null +++ b/app/utils/docs.ts @@ -0,0 +1,87 @@ +import { extractFrontMatter, fetchRepoFile } from '~/utils/documents.server' +import removeMarkdown from 'remove-markdown' +import { notFound } from '@tanstack/react-router' +import { createServerFn, json } from '@tanstack/start' + +export const loadDocs = async ({ + repo, + branch, + docsPath, + currentPath, + redirectPath, +}: { + repo: string + branch: string + docsPath: string + currentPath: string + redirectPath: string +}) => { + if (!branch) { + throw new Error('Invalid branch') + } + + if (!docsPath) { + throw new Error('Invalid docPath') + } + + const filePath = `${docsPath}.md` + + return await fetchDocs({ + repo, + branch, + filePath, + currentPath, + redirectPath, + }) +} + +export const fetchDocs = createServerFn( + 'GET', + async ({ + repo, + branch, + filePath, + currentPath, + redirectPath, + }: { + repo: string + branch: string + filePath: string + currentPath: string + redirectPath: string + }) => { + 'use server' + + const file = await fetchRepoFile(repo, branch, filePath) + + if (!file) { + throw notFound() + // if (currentPath === redirectPath) { + // // console.log('not found') + // throw notFound() + // } else { + // // console.log('redirect') + // throw redirect({ + // to: redirectPath, + // }) + // } + } + + const frontMatter = extractFrontMatter(file) + const description = removeMarkdown(frontMatter.excerpt ?? '') + + return json( + { + title: frontMatter.data?.title, + description, + filePath, + content: frontMatter.content, + }, + { + headers: { + 'Cache-Control': 's-maxage=1, stale-while-revalidate=300', + }, + } + ) + } +) diff --git a/app/utils/documents.server.ts b/app/utils/documents.server.ts new file mode 100644 index 000000000..8195896a6 --- /dev/null +++ b/app/utils/documents.server.ts @@ -0,0 +1,200 @@ +import fsp from 'node:fs/promises' +import path from 'node:path' +// import { fileURLToPath } from 'node:url' +import * as graymatter from 'gray-matter' +import { fetchCached } from '~/utils/cache.server' + +export type Doc = { + filepath: string +} + +export type DocFrontMatter = { + title: string + published?: string + exerpt?: string +} + +/** + * Return text content of file from remote location + */ +async function fetchRemote( + owner: string, + repo: string, + ref: string, + filepath: string +) { + const href = new URL( + `${owner}/${repo}/${ref}/${filepath}`, + 'https://raw.githubusercontent.com/' + ).href + + const response = await fetch(href, { + headers: { 'User-Agent': `docs:${owner}/${repo}` }, + }) + + if (!response.ok) { + return null + } + + return await response.text() +} + +/** + * Return text content of file from local file system + */ +async function fetchFs(repo: string, filepath: string) { + // const __dirname = fileURLToPath(new URL('.', import.meta.url)) + const dirname = import.meta.url.split('://').at(-1)! + const localFilePath = path.resolve(dirname, `../../../../${repo}`, filepath) + const file = await fsp.readFile(localFilePath) + return file.toString() +} + +/** + * Perform global string replace in text for given key-value map + */ +function replaceContent( + text: string, + frontmatter: graymatter.GrayMatterFile +) { + let result = text + const replace = frontmatter.data.replace as Record | undefined + if (replace) { + Object.entries(replace).forEach(([key, value]) => { + result = result.replace(new RegExp(key, 'g'), value) + }) + } + + return result +} + +/** + * Perform tokenized sections replace in text. + * - Discover sections based on token marker via RegExp in origin file. + * - Discover sections based on token marker via RegExp in target file. + * - replace sections in target file staring from the end, with sections defined in origin file + * @param text File content + * @param frontmatter Referencing file front-matter + * @returns File content with replaced sections + */ +function replaceSections( + text: string, + frontmatter: graymatter.GrayMatterFile +) { + let result = text + // RegExp defining token pair to dicover sections in the document + // [//]: # (
) + const sectionMarkerRegex = /\[\/\/\]: # '([a-zA-Z\d]*)'/g + const sectionRegex = + /\[\/\/\]: # '([a-zA-Z\d]*)'[\S\s]*?\[\/\/\]: # '([a-zA-Z\d]*)'/g + + // Find all sections in origin file + const substitutes = new Map() + for (const match of frontmatter.content.matchAll(sectionRegex)) { + if (match[1] !== match[2]) { + console.error( + `Origin section '${match[1]}' does not have matching closing token (found '${match[2]}'). Please make sure that each section has corresponsing closing token and that sections are not nested.` + ) + } + + substitutes.set(match[1], match) + } + + // Find all sections in target file + const sections = new Map() + for (const match of result.matchAll(sectionRegex)) { + if (match[1] !== match[2]) { + console.error( + `Target section '${match[1]}' does not have matching closing token (found '${match[2]}'). Please make sure that each section has corresponsing closing token and that sections are not nested.` + ) + } + + sections.set(match[1], match) + } + + Array.from(substitutes.entries()) + .reverse() + .forEach(([key, value]) => { + const sectionMatch = sections.get(key) + if (sectionMatch) { + result = + result.slice(0, sectionMatch.index!) + + value[0] + + result.slice( + sectionMatch.index! + sectionMatch[0].length, + result.length + ) + } + }) + + // Remove all section markers from the result + result = result.replaceAll(sectionMarkerRegex, '') + + return result +} + +export async function fetchRepoFile( + repoPair: string, + ref: string, + filepath: string +) { + const key = `${repoPair}:${ref}:${filepath}` + let [owner, repo] = repoPair.split('/') + + const ttl = process.env.NODE_ENV === 'development' ? 1 : 1 * 60 * 1000 // 5 minute + const file = await fetchCached({ + key, + ttl, + fn: async () => { + const maxDepth = 4 + let currentDepth = 1 + let originFrontmatter: graymatter.GrayMatterFile | undefined + while (maxDepth > currentDepth) { + let text: string | null + // Read file contents + try { + if (process.env.NODE_ENV === 'development') { + text = await fetchFs(repo, filepath) + } else { + text = await fetchRemote(owner, repo, ref, filepath) + } + } catch (err) { + console.error(err) + return null + } + + if (text === null) { + return null + } + try { + const frontmatter = extractFrontMatter(text) + // If file does not have a ref in front-matter, replace necessary content + if (!frontmatter.data.ref) { + if (originFrontmatter) { + text = replaceContent(text, originFrontmatter) + text = replaceSections(text, originFrontmatter) + } + return Promise.resolve(text) + } + // If file has a ref to another file, cache current front-matter and load referenced file + filepath = frontmatter.data.ref + originFrontmatter = frontmatter + } catch (error) { + return Promise.resolve(text) + } + currentDepth++ + } + + return null + }, + }) + + return file +} + +export function extractFrontMatter(content: string) { + return graymatter.default(content, { + excerpt: (file: any) => + (file.excerpt = file.content.split('\n').slice(0, 4).join('\n')), + }) +} diff --git a/src/utils/handleRedirects.ts b/app/utils/handleRedirects.server.ts similarity index 50% rename from src/utils/handleRedirects.ts rename to app/utils/handleRedirects.server.ts index 0cb6de38b..2e2e9b547 100644 --- a/src/utils/handleRedirects.ts +++ b/app/utils/handleRedirects.server.ts @@ -3,25 +3,21 @@ import { redirect } from '@tanstack/react-router' type RedirectItem = { from: string; to: string } export function handleRedirects( - redirectItems: Array, + redirectItems: RedirectItem[], urlFromRequest: string, urlFromPathStart: string, urlToPathStart: string, - urlToQueryParams: string, + urlToQueryParams: string ) { - const origin = - typeof window !== 'undefined' - ? window.location.origin - : 'https://tanstack.com' - - const url = new URL(urlFromRequest, origin) + const url = new URL(urlFromRequest, 'https://tanstack.com') redirectItems.forEach((item) => { - const fromPath = `${urlFromPathStart}/${item.from}`.replace(/\/+/g, '/') - const matchesPath = - url.pathname === fromPath || url.pathname.startsWith(`${fromPath}/`) - - if (matchesPath) { + if (url.pathname.startsWith(`${urlFromPathStart}/${item.from}`)) { + /* + We create a URL object from the destination route before + adding the query params to make sure that the URL hash + (#this-part) is preserved. + */ const urlTo = new URL(`${url.origin}${urlToPathStart}/${item.to}`) urlTo.search = urlToQueryParams diff --git a/app/utils/sandbox.ts b/app/utils/sandbox.ts new file mode 100644 index 000000000..b6c8c2393 --- /dev/null +++ b/app/utils/sandbox.ts @@ -0,0 +1,28 @@ +import { type Framework } from '~/libraries' + +export const getInitialSandboxFileName = ( + framework: Framework, + libraryId?: string +) => { + const dir = 'src' + + const file = + framework === 'angular' + ? 'app.component' + : ['svelte', 'vue'].includes(framework) + ? 'App' + : ['form', 'query'].includes(libraryId!) + ? 'index' + : 'main' + + const ext = + framework === 'svelte' + ? 'svelte' + : framework === 'vue' + ? 'vue' + : ['angular', 'lit'].includes(framework) + ? 'ts' + : 'tsx' + + return `${dir}/${file}.${ext}` as const +} diff --git a/app/utils/sentry.ts b/app/utils/sentry.ts new file mode 100644 index 000000000..107a3dbe9 --- /dev/null +++ b/app/utils/sentry.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/react' + +Sentry.init({ + dsn: 'https://ac4bfc43ff4a892f8dc7053c4a50d92f@o4507236158537728.ingest.us.sentry.io/4507236163649536', + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.replayIntegration(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled + tracePropagationTargets: ['localhost', /^https:\/\/tanstack\.com\//], + // Session Replay + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. +}) diff --git a/app/utils/seo.ts b/app/utils/seo.ts new file mode 100644 index 000000000..d18ad84b7 --- /dev/null +++ b/app/utils/seo.ts @@ -0,0 +1,33 @@ +export const seo = ({ + title, + description, + keywords, + image, +}: { + title: string + description?: string + image?: string + keywords?: string +}) => { + const tags = [ + { title }, + { name: 'description', content: description }, + { name: 'keywords', content: keywords }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + { name: 'twitter:creator', content: '@tannerlinsley' }, + { name: 'twitter:site', content: '@tannerlinsley' }, + { name: 'og:type', content: 'website' }, + { name: 'og:title', content: title }, + { name: 'og:description', content: description }, + ...(image + ? [ + { name: 'twitter:image', content: image }, + { name: 'twitter:card', content: 'summary_large_image' }, + { name: 'og:image', content: image }, + ] + : []), + ] + + return tags +} diff --git a/app/utils/useClientOnlyRender.ts b/app/utils/useClientOnlyRender.ts new file mode 100644 index 000000000..0b4547a5c --- /dev/null +++ b/app/utils/useClientOnlyRender.ts @@ -0,0 +1,9 @@ +import { useEffect, useState } from 'react' + +export function useClientOnlyRender() { + const [rendered, setRendered] = useState(false) + useEffect(() => { + setRendered(true) + }, []) + return rendered +} diff --git a/app/utils/useLocalStorage.ts b/app/utils/useLocalStorage.ts new file mode 100644 index 000000000..5bd1592e0 --- /dev/null +++ b/app/utils/useLocalStorage.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react' + +function getWithExpiry(key: string) { + if (typeof window !== 'undefined') { + const itemStr = localStorage.getItem(key) + // if the item doesn't exist, return undefined + if (!itemStr) { + return undefined + } + const item: { value: T; ttl: number } = JSON.parse(itemStr) + // If there is no TTL set, return the value + if (!item.ttl) { + return item.value + } + // compare the expiry time of the item with the current time + if (new Date().getTime() > item.ttl) { + // If the item is expired, delete the item from storage + localStorage.removeItem(key) + return undefined + } + return item.value + } +} + +export function useLocalStorage( + key: string, + defaultValue: T, + ttl?: number +): [T, typeof setValue] { + const [value, setValue] = useState(defaultValue) + + useEffect(() => { + const item = getWithExpiry(key) + if (item !== undefined) setValue(item) + }, [key]) + + useEffect(() => { + localStorage.setItem( + key, + JSON.stringify({ + value, + ttl: ttl ? new Date().getTime() + ttl : null, + }) + ) + }, [key, value, ttl]) + + return [value, setValue] +} diff --git a/app/utils/utils.ts b/app/utils/utils.ts new file mode 100644 index 000000000..63148324b --- /dev/null +++ b/app/utils/utils.ts @@ -0,0 +1,88 @@ +export function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +export function slugToTitle(str: string) { + return str + .split('-') + .map((word) => capitalize(word)) + .join(' ') +} + +// export const tw = { +// group: (prefix: string, tokens: string) => { +// return tokens +// .split(' ') +// .map((d) => `${prefix}${d}`) +// .join(' ') +// }, +// } + +export function last(arr: T[]) { + return arr[arr.length - 1] +} + +// Generates path replacing tokens with params +export function generatePath( + id: string, + params: Record +) { + let result = id.replace('routes', '').replaceAll('.', '/') + Object.entries(params).forEach(([key, value]) => { + result = result.replace(`$${key}`, value!) + }) + result = result.replace('$', params['*']!) + + return result +} + +export function shuffle(arr: T[]) { + const random = Math.random() + const result = arr.slice() + + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(random * (i + 1)) + const temp = result[i] + result[i] = result[j] + result[j] = temp + } + + return result +} + +export function sample(arr: any[], random = Math.random()) { + return arr[Math.floor(random * arr.length)] +} + +export function sortBy(arr: T[], accessor: (d: T) => any = (d) => d): T[] { + return arr + .map((d: any, i: any) => [d, i]) + .sort(([a, ai], [b, bi]) => { + a = accessor(a) + b = accessor(b) + + if (typeof a === 'undefined') { + if (typeof b === 'undefined') { + return 0 + } + return 1 + } + + a = isNumericString(a) ? Number(a) : a + b = isNumericString(b) ? Number(b) : b + + return a === b ? ai - bi : a > b ? 1 : -1 + }) + .map((d: any) => d[0]) +} + +export function isNumericString(str: string): boolean { + if (typeof str !== 'string') { + return false // we only process strings! + } + + return ( + !isNaN(str as unknown as number) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... + !isNaN(parseFloat(str)) + ) // ...and ensure strings of whitespace fail +} diff --git a/content-collections.ts b/content-collections.ts deleted file mode 100644 index ed3891941..000000000 --- a/content-collections.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { defineCollection, defineConfig } from '@content-collections/core' -import { libraryIds } from '~/libraries/libraries' -import { normalizeRedirectFrom } from '~/utils/redirects' -import { z } from 'zod' - -const libraryIdSet = new Set(libraryIds) -const libraryListSchema = z.string().refine( - (value) => { - const libraries = value - .split(',') - .map((library) => library.trim()) - .filter(Boolean) - - return ( - libraries.length > 0 && - libraries.every((library) => libraryIdSet.has(library)) - ) - }, - { - message: `Expected comma-separated library ids: ${libraryIds.join(', ')}`, - }, -) - -const posts = defineCollection({ - name: 'posts', - directory: './src/blog', - include: '*.md', - schema: z.object({ - title: z.string(), - published: z.iso.date(), - draft: z.boolean().optional(), - excerpt: z.string(), - authors: z.string().array(), - library: libraryListSchema.optional(), - content: z.string(), - redirect_from: z.string().array().optional(), - }), - transform: ({ content, ...post }) => { - // Extract header image (first image after frontmatter) - const headerImageMatch = content.match(/!\[([^\]]*)\]\(([^)]+)\)/) - const headerImage = headerImageMatch ? headerImageMatch[2] : undefined - const redirectFrom = normalizeRedirectFrom(post.redirect_from) - - return { - ...post, - slug: post._meta.path, - headerImage, - redirect_from: redirectFrom, - redirectFrom, - content, - } - }, -}) - -export default defineConfig({ - content: [posts], -}) diff --git a/discord-bot/package.json b/discord-bot/package.json new file mode 100644 index 000000000..9ec542801 --- /dev/null +++ b/discord-bot/package.json @@ -0,0 +1,22 @@ +{ + "name": "discord-bot", + "version": "1.0.0", + "main": "dist/index.js", + "author": "Tanner Linsley", + "license": "MIT", + "private": true, + "scripts": { + "start": "rollup src/index.js --file dist/index.js --format cjs --watch", + "build": "rollup src/index.js --file dist/index.js --format cjs", + "serve": "nodemon ./dist/index.js localhost 8080", + "heroku-serve": "node ./dist/index.js" + }, + "dependencies": { + "discord.js": "^12.5.1", + "dotenv": "^8.2.0" + }, + "devDependencies": { + "nodemon": "^2.0.7", + "rollup": "^2.38.0" + } +} diff --git a/discord-bot/src/index.js b/discord-bot/src/index.js new file mode 100644 index 000000000..e583e3c03 --- /dev/null +++ b/discord-bot/src/index.js @@ -0,0 +1,73 @@ +import path from 'path' +import Discord from 'discord.js' + +if (process.env.NODE_ENV !== 'production') { + require('dotenv').config({ + path: path.resolve(process.cwd(), '../', '.env'), + }) +} + +const guildId = '719702312431386674' + +const channelIds = { + welcome: '725435640673468500', + fan: '803508045627654155', + supporter: '803508117752119307', + premierSponsor: '803508359378370600', +} + +const roles = { + fan: '🤘Fan', + supporter: '🎗Supporter', + permierSponsor: '🏅Premier Sponsor', +} + +let clientPromise + +init() + +function getClient() { + if (!clientPromise) { + clientPromise = new Promise((resolve) => { + const client = new Discord.Client() + + client.on('ready', async () => { + console.info('Logged in to Discord.') + resolve(client) + }) + + client.login(process.env.DISCORD_TOKEN) + }) + } + + return clientPromise +} + +async function getGuild() { + const client = await getClient() + return await client.guilds.fetch(guildId) +} + +async function init() { + const client = await getClient() + + // const guild = await getGuild() + + // console.info(guild) + + client.on('message', (message) => { + // console.info(message) + // let tierRole = message.guild.roles.cache.find( + // (role) => role.name === roles[tier.sponsorType] + // ) + }) + + //Welcome & goodbye messages\\ + client.on('guildMemberAdd', (member) => { + console.info(member) + + member.roles.add( + member.guild.roles.cache.find((i) => i.name === 'Among The Server') + ) + }) +} diff --git a/docs-info.md b/docs-info.md deleted file mode 100644 index a13e6ef65..000000000 --- a/docs-info.md +++ /dev/null @@ -1,272 +0,0 @@ -# Maintainer Documentation Guide - -This page covers the markdown features supported in TanStack docs and the preferred workflow for future redirects. - -## Redirects - -For new page moves or consolidations, keep the canonical page and list old URLs in frontmatter with `redirect_from`. - -```yaml ---- -title: Overview -redirect_from: - - /framework/react/overview - - /framework/solid/overview ---- -``` - -In the example above, old framework-specific URLs will redirect to the shared `/overview` page without needing to add a new entry to the central redirect files in the `tanstack.com` repo. - -## Supported markdown - -Docs support normal GitHub-flavored markdown, including: - -- headings -- links -- lists -- tables -- fenced code blocks -- images -- blockquotes -- task lists - -## Callouts - -GitHub-style alerts are supported. For a customized title, for example to replace `Note` with something else, you can use the syntax `> [!TYPE] Title`: - -```md -> [!NOTE] Custom title -> Use `redirect_from` on the canonical page when docs are moved. - -> [!TIP] -> Prefer absolute paths like `/framework/react/overview`. -``` - -## Code Blocks - -Fenced code blocks are supported with language identifiers for syntax highlighting. You can also add metadata like `title="..."` for file tabs. - -````md -```tsx title="app.tsx" -export function App() { - return
Hello
-} -``` -```` - -## Tabs component - -The tabs component lets you group content into tabbed sections. It supports multiple variants, including file tabs and package manager tabs. - -### File tabs - -Use `variant="files"` when the block should render code examples as file tabs. It scans consecutive code blocks, extracts language, title, and content, and uses that to build `file-tab` data. Titles come from code-block metadata such as `title="..."` or will default to the language name if not provided. - -````md - - -```tsx file="app.tsx" -export function App() { - return
Hello
-} -``` - -```css file="styles.css" -body { - color: tomato; -} -``` - - -```` - -### What matters - -- use fenced code blocks -- add `title="..."` if you want meaningful file tab labels -- language comes from the code fence language -- code text is extracted from the `` node content - -### Package manager tabs - -Package-manager tabs are a special `tabs` variant. The parser reads framework lines like `react: ...` or `solid: ...`, groups packages, and later generates package-manager-specific commands. - -There are various supported package manager formats, including npm, yarn, pnpm, and bun. - -If you're looking to support a multi-line command, you can add multiple instances of the same framework. For example: - -```md - - -react: -react: - - -``` - -This will become: - -```bash -npm i -npm i -``` - -#### Supported modes - -- `install` (default) -- `dev-install` -- `local-install` -- `create` -- `custom` (for custom command templates) - -##### Install (default) - -```md - - -react: -solid: - - -``` - -becomes - -```bash -npm i -``` - -##### Dev install - -```md - - -react: -solid: - - -``` - -becomes - -```bash -npm i -D -``` - -##### Local install - -```md - - -react: -solid: - - -``` - -becomes - -```bash -npx --workspace=./path/to/workspace -``` - -##### Create - -```md - - -react: -solid: - - -``` - -becomes - -```bash -npm create -``` - -##### Custom - -```md - - -react: -solid: - - -``` - -becomes - -```bash -npm -``` - -### Bundler tabs - -Bundler tabs render a compact tab row (like package-manager tabs) but accept rich markdown content per bundler (like the framework component). The user's bundler choice is persisted to `localStorage` and synced across every bundler tab block on the page. - -Inside `variant="bundler"`, each top-level heading whose text matches a known bundler starts a new section, and the following nodes (prose, code blocks, etc.) become that bundler's panel. The transformer uses the largest heading level present in the block, so `# Vite` / `# Rsbuild` and `## Vite` / `## Rsbuild` both work — just be consistent within a single block. - -````md - - -# Vite - -```ts title="vite.config.ts" -import { defineConfig } from 'vite' - -export default defineConfig({}) -``` - -# Rsbuild - -```ts title="rsbuild.config.ts" -import { defineConfig } from '@rsbuild/cli' - -export default defineConfig({}) -``` - - -```` - -Supported bundlers: `vite`, `rsbuild`. Heading text is matched case-insensitively. Both sections should be defined; if the user's selected bundler isn't present in a particular block, the first defined panel is shown as a fallback. - -## Framework component - -Framework blocks let one markdown source contain React, Solid, or other framework-specific content. Internally, the transformer looks for h1 headings inside the framework block and treats each `# Heading` as a framework section boundary. It then stores framework metadata and rewrites the block into separate framework panels. - -> **Note**: This should only be used when the majority of the content is the same. If the content is mostly different, it's better to create separate markdown files for each framework and use redirects to point to the canonical page. - -````md - - -# React - -Use the React adapter here. - -```tsx -// React code -``` - -# Solid - -Use the Solid adapter here. - -```tsx -// Solid Code -``` - - -```` - -Each top-level `#` heading becomes a framework panel. Nested headings inside a framework section are preserved for the table of contents. - -## Editing notes - -- Keep redirects on the surviving page, not on the page being removed. -- Use absolute paths in `redirect_from`. -- Avoid duplicate `redirect_from` values across pages. -- Existing central redirect files still handle older redirects; use frontmatter for new ones going forward. diff --git a/docs/cloudflare-workers-migration.md b/docs/cloudflare-workers-migration.md deleted file mode 100644 index 006beca63..000000000 --- a/docs/cloudflare-workers-migration.md +++ /dev/null @@ -1,135 +0,0 @@ -# Cloudflare Workers Migration Report - -TanStack.com is configured as a Cloudflare Workers deployment for this branch. Netlify is no longer part of this site's hosting configuration, but Netlify remains available in builder/deploy-provider UX for users who choose it. - -## Files Changed - -- `package.json`, `pnpm-lock.yaml`: Cloudflare build/preview/deploy scripts, `@cloudflare/vite-plugin`, `wrangler`, `@tanstack/create@0.68.4`, and removal of Netlify hosting packages. -- `vite.config.ts`: Cloudflare Vite plugin, Worker build constants, opt-in image transformation flag, and server builder-generation enabled with the Worker-safe create API. -- `wrangler.jsonc`: Worker name/account, assets binding, `nodejs_compat`, CPU limit, cron triggers, and production `SITE_URL`. -- `src/server.ts`, `src/server/scheduled.server.ts`: Worker `fetch` and `scheduled` entrypoints, replacing former Netlify scheduled functions. -- `src/server/runtime/host.server.ts`, `src/utils/hosting-cache.server.ts`: host cache purge adapter using Cloudflare cache-tag purge. -- `src/components/OptimizedImage.tsx`, `src/utils/optimizedImage.ts`: host-neutral optimized image helper with Cloudflare image transformations behind an explicit build flag. -- `src/builder/api/create-worker.ts`: host-neutral adapter around `@tanstack/create/worker` with local Worker manifest loaders for supported frameworks and add-ons. -- `src/builder/api/*`, `src/routes/api/builder/*`: builder feature catalog, compile, download, validate, suggest, and GitHub deploy paths use the Worker-safe create adapter. -- `src/routes/*`, `src/utils/*`, `src/server/*`: CDN cache headers moved from Netlify-specific headers to Cloudflare Workers Cache headers / `Cache-Tag`. -- `src/utils/markdown/processor.ts`: site-side compatibility guard for escaped angle brackets in generated TypeDoc markdown until `@tanstack/markdown` handles `\<...\>` as escaped text. -- `package.json`, `pnpm-lock.yaml`: `@tanstack/markdown@0.0.5` for compact table delimiters, footnotes, and legacy single-tilde strike headings without the temporary pnpm patch. -- Removed hosting-only Netlify files: `netlify.toml`, `netlify/functions/*`, `scripts/run-built-server.mjs`. - -## Commands Used - -```bash -pnpm install --lockfile-only -pnpm add @tanstack/create@^0.68.4 -pnpm run test:tsc -pnpm exec tsc --noEmit -pnpm run build:cloudflare -pnpm test -pnpm run dev:cloudflare -pnpm run deploy:cloudflare -pnpm run preview:cloudflare -- --host 127.0.0.1 --port 3001 -pnpm install -``` - -Additional checks used `curl`, Node fetch scripts, Wrangler tail, and Playwright with system Chrome against the Workers preview URL. - -## Deploy Cache Purge - -`pnpm run deploy:cloudflare` builds, deploys the Worker, then runs `pnpm run purge:cloudflare`. The purge step clears the Cloudflare zone cache with `purge_everything` so stale HTML documents cannot keep pointing at removed route chunks after a deploy. - -Required environment: - -- `CLOUDFLARE_ZONE_ID`: the active `tanstack.com` zone ID. -- `CLOUDFLARE_CACHE_PURGE_TOKEN`: Cloudflare API token with `Cache Purge` permission for that zone. - -`CLOUDFLARE_API_TOKEN` / `CF_API_TOKEN` and `CF_ZONE_ID` are accepted as aliases. If `CLOUDFLARE_ZONE_ID` is missing, the script can discover it only when the token also has zone read access. - -## Worker - -- Account: `8da95258a9c70b54c3e2b374a0079106` -- Worker: `tanstack-com` -- URL: `https://tanstack-com.thetanstack.workers.dev` -- Current version: `ff011a60-320b-43b7-9a03-bd650a41bc7b` -- Upload size: `17748.26 KiB` raw, `6413.10 KiB` gzip -- Startup time: `33 ms` -- Note: the secret-bearing `tanstack-com-staging` Worker was renamed to `tanstack-com`, and the older empty `tanstack-com` Worker was removed. - -## Passed - -- `pnpm run build:cloudflare` passed. -- `pnpm run test:tsc` passed. -- `pnpm test` passed with 10 existing oxlint warnings. -- Cloudflare deploy passed. -- Local Cloudflare preview started and returned 200 for `/` and `/builder`. -- `/` returned 200 HTML on the Worker. -- `/start/latest` returned 200 HTML on the Worker. -- Browser SPA navigation from `/` to `/start/latest` did not reproduce the `npm-recent-downloads ... data is undefined` error. -- `/builder` returned 200 HTML with COOP/COEP headers and loaded the client builder/integration surface. -- Primary homepage images loaded in browser on the Worker. -- `/api/og/query.png?title=Query&description=Smoke` returned a valid 1200x630 PNG. -- `/_a/gtag.js` returned Google's JavaScript. -- `/_a/g/collect` returned 204. -- `/auth/github/start?returnTo=/account` redirected to GitHub with secure state/return cookies. -- `/.well-known/oauth-authorization-server` returned OAuth metadata. -- `/api/mcp/` returned the expected unauthenticated JSON-RPC auth error instead of a runtime failure. -- `POST /api/application-starter/resolve` returned a Start recipe. -- `/api/builder/features?framework=react` returned the Worker-safe catalog, including `tanstack-query`, `cloudflare`, and `biome`. -- `/api/builder/compile` generated representative React and Solid projects server-side on the Worker. -- `/api/builder/download` returned a valid zip for a representative React builder project. -- `https://tanstack.com/api/builder/features?framework=react` returned the same Worker-safe builder catalog. -- `https://tanstack.com/builder` returned 200 HTML with COOP/COEP headers. -- Worker output does not include `generated/create-manifest.js` or a `create-manifest` chunk. -- Cloudflare dry-run/upload size stayed below the paid Worker 10 MiB gzip limit after excluding the heavy React `events` example implementation chunk. -- `Link` response headers for static assets are emitted on SSR responses for Cloudflare Early Hints fallback. -- Broad docs/blog audit generated 2,767 latest-doc/blog URLs from GitHub doc trees plus local blog posts and compared production vs Worker. -- Escaped generic headings in TypeDoc markdown now render correctly, e.g. `Interface: AudioAdapter` with the production-compatible `interface-audioadaptertmodel-tprovideroptions` anchor. -- Local site parser checks now match the remaining markdown audit diffs: - - `/ai/latest/docs/code-mode/code-mode` parses 4 content tables. - - `/db/latest/docs/collections/powersync-collection` parses 24 content tables. - - `/start/latest/docs/framework/react/migrate-from-next-js` headings render as `Server Actions Functions` and `Server Routes Handlers`. - - `/blog/tanstack-router-signal-graph` renders footnotes with `data-footnotes` and production-compatible footnote anchors. -- Live Worker rechecks against production passed for the same table, heading, and footnote signals after deploying version `586b99ec-4a2c-46d2-b3b3-eb775de13141`. -- Three full-body rechecks of 43 URLs that intermittently returned Worker 500/timeout during the high-concurrency audit cleared; the only stable non-200s were `/hotkeys/latest/docs/reference` and `/pacer/latest/docs/reference`, both 404 on production and Worker. - -## Failed Or Not Proven - -- Full GitHub OAuth callback/account login was not completed. -- End-to-end GitHub repository deploy was not completed with a logged-in account. -- Cron trigger behavior was deployed but not manually invoked. -- The React `events` example is not exposed in the Worker builder catalog because its implementation chunk pushes the Worker over Cloudflare's paid 10 MiB gzip upload limit. -- High-concurrency audit runs can still produce transient Worker 500s with `{"status":500,"unhandled":true,"message":"HTTPError"}` on changing docs paths, but targeted full-body rechecks did not reproduce stable page failures. Treat this as a load/audit-cache risk, not a confirmed content regression. - -## Builder Generation Note - -`@tanstack/create@0.68.4` adds the lazy `@tanstack/create/worker` API. The site now imports that API through `src/builder/api/create-worker.ts` instead of importing `@tanstack/create/edge` from route logic. - -`/api/builder/features` remains catalog-only by using `create.getFrameworkById` and `create.getAllAddOns`. ZIP/project generation loads only the requested framework/add-on chunks, then calls `create.finalizeAddOns`, `create.populateAddOnOptionsDefaults`, `createMemoryEnvironment`, and `create.createApp`. - -The Worker build was checked for `generated/create-manifest.js` and `create-manifest` output, and no generated manifest bundle was present. Including the React `events` example implementation still pushed upload size to `11179.57 KiB` gzip, so the Worker loader intentionally omits that chunk. The deployed Worker is `6413.10 KiB` gzip. - -## Image Transformation Note - -Cloudflare image transformations are enabled for `build:cloudflare`. Transformed image URLs are emitted through the configured `SITE_URL` origin because `/cdn-cgi/image/*` works on the routed `tanstack.com` zone but still returns 404 on the `workers.dev` hostname. - -## Markdown Audit Note - -The new markdown renderer initially parsed escaped TypeDoc generics like `\` as inline HTML. The site now protects escaped `<` / `>` outside code fences and inline code before parsing, restores them into text nodes before render, and rebuilds headings so rendered content and ToC anchors stay aligned. - -Resolved markdown differences from the broad audit: - -- Escaped TypeDoc generics no longer become inline HTML. -- Compact markdown table delimiters like `:--:` now parse as tables, matching the existing production renderer. -- Blog footnotes now render with `data-footnotes`, `user-content-fn-*`, and `user-content-fnref-*` anchors. -- Legacy single-tilde strike headings now render without visible `~` markers. - -Remaining markdown differences observed during audit: - -- Production duplicates light/dark code blocks; the Worker branch renders one theme-aware code block. This explains large HTML-size and `
` count differences.
-- The temporary `@tanstack/markdown@0.0.4` pnpm patch has been removed after upgrading to `@tanstack/markdown@0.0.5`.
-
-## Readiness
-
-Core marketing SSR, docs/start navigation, security headers, static assets, analytics proxying, GitHub auth start, MCP auth rejection, application-starter API, scheduled Worker registration, Cloudflare preview, deploy, dynamic OG image generation, and representative Worker-side builder generation are working on Cloudflare Workers.
-
-Production migration is close, but not fully safe until logged-in OAuth/account flows pass, cron jobs are verified, and an authenticated builder GitHub deploy is completed. The main remaining builder gap is the omitted React `events` example chunk.
diff --git a/docs/ecosystem-implementation-todo.md b/docs/ecosystem-implementation-todo.md
deleted file mode 100644
index 8383b310a..000000000
--- a/docs/ecosystem-implementation-todo.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# Ecosystem Implementation Todo
-
-## Goal
-
-Build a database-driven `/ecosystem` marketplace and submission flow without blocking the partner-page SEO work.
-
-## Product Goals
-
-- Create an `/ecosystem` directory page that acts like a marketplace
-- Let users sort and filter entries by the fields that actually matter
-- Let companies submit new listings through a first-party submission flow
-- Seed the marketplace with existing TanStack partners when the DB work starts
-- Let TanStack maintainers add an official review and comparisons to each listing
-- Keep partner-backed entries aligned with `/partners/[partner]` pages
-
-## Scope To Build Later
-
-### Data model
-
-- Add a new `ecosystemEntries` table
-- Add a dedicated moderation capability like `moderate-ecosystem`
-- Keep the schema intentionally small and high-value
-
-Suggested v1 fields:
-
-- `id`
-- `userId`
-- `slug`
-- `name`
-- `websiteUrl`
-- `kind`
-- `tags`
-- `libraries`
-- `pricingModel`
-- `isOpenSource`
-- `preferenceScore`
-- `isPartner`
-- `logoUrl`
-- `screenshotUrl`
-- `summary`
-- `officialReviewMd`
-- `competitorSlugs`
-- `status`
-- `moderationNote`
-- `moderatedBy`
-- `moderatedAt`
-- `createdAt`
-- `updatedAt`
-
-### Taxonomy
-
-Use a very small taxonomy surface:
-
-- `kind`: `integration | service | tool | template | plugin`
-- `pricingModel`: `free | freemium | paid | contact | oss`
-- `tags: string[]` absorbs both "type" and "service"
-
-### Comparison model
-
-Use slugs everywhere.
-
-- Partners compare against other partner ids, which are also their slugs
-- Ecosystem entries compare against ecosystem slugs
-- Partner-backed ecosystem entries should reuse the same slug namespace
-
-Suggested v1 field:
-
-- `competitorSlugs: string[]`
-
-No UUID-based comparison references for v1.
-No partner-only comparison system.
-No extra comparison tables unless the simple shape breaks down.
-
-### Canonical URL rules
-
-- Partner-backed entries should be canonical at `/partners/[partner]`
-- Non-partner entries should be canonical at `/ecosystem/[slug]`
-- If a partner-backed entry is reachable from `/ecosystem/[slug]`, redirect or canonicalize to `/partners/[partner]`
-
-### Submission flow
-
-Build a user-facing submission portal similar in spirit to showcase, but for ecosystem listings.
-
-Suggested v1 required fields:
-
-- `name`
-- `websiteUrl`
-- `kind`
-- `tags`
-- `libraries`
-- `pricingModel`
-- `isOpenSource`
-- `logoUrl`
-- `screenshotUrl`
-- `summary`
-
-Maintainer-only fields for v1:
-
-- `preferenceScore`
-- `officialReviewMd`
-- `competitorSlugs`
-- moderation fields
-
-### Admin flow
-
-- Add `/admin/ecosystem`
-- Add `/admin/ecosystem/[id]`
-- Let maintainers moderate submissions
-- Let maintainers edit all listing fields
-- Let maintainers set `preferenceScore`
-- Let maintainers author `officialReviewMd`
-- Let maintainers manage `competitorSlugs`
-
-### Account flow
-
-- Add `/account/ecosystem`
-- Let users view, edit, and delete their own submissions
-- Keep this separate from `/account/integrations`, which already means something else
-
-## Seeding Plan
-
-- Seed existing partners into the ecosystem table when the DB work starts
-- Preserve current partner ids as ecosystem slugs for partner-backed entries
-- Seed `preferenceScore` from the existing `partners[].score` values
-- Start seeded partner entries as `approved`
-- Keep the current score scale and revisit later if needed
-
-## Suggested Implementation Order
-
-1. Add ecosystem capability, enums, and DB schema
-2. Add ecosystem server functions and query options
-3. Seed partner rows into the new table
-4. Build `/ecosystem` index and `/ecosystem/[slug]`
-5. Build `/ecosystem/submit` and edit flows
-6. Build `/account/ecosystem`
-7. Build `/admin/ecosystem`
-8. Add SEO metadata and JSON-LD
-9. Run `pnpm test`
-
-## Notes
-
-- Reuse showcase architecture patterns, not the showcase schema
-- Keep the implementation intentionally small at first
-- Avoid over-modeling comparison data until real usage demands it
-- Partner pages ship first; ecosystem follows later
diff --git a/docs/partner-placement.md b/docs/partner-placement.md
deleted file mode 100644
index 0d7b35f94..000000000
--- a/docs/partner-placement.md
+++ /dev/null
@@ -1,70 +0,0 @@
-# Partner Placement
-
-Partner placement is split into three separate decisions:
-
-- **Eligibility:** whether a partner can appear for a surface, category, library, feature, or user context.
-- **Tier:** the partner's commercial/display tier, which controls visual treatment and relative access to surfaces.
-- **Order:** the sequence used inside a surface once eligible partners are known.
-
-The order policy should be explicit per surface. Avoid treating the partner array order or legacy score as the policy itself.
-
-## Order Strategies
-
-### `static-curated`
-
-Use for surfaces where the order is editorially or commercially curated and should remain stable. These surfaces can preserve the existing order, use partner seniority, or use private commercial priority when seniority is unavailable.
-
-Do not expose private commercial criteria in client-facing field names, comments, or API responses. If a private priority is required, keep the public/client value generic, such as `placementPriority` or an already-resolved ordered list.
-
-### `tier-rotated`
-
-Use for visible partner surfaces where partners in the same tier should cycle over time. Tier order remains fixed, but partners inside the same tier are selected with weighted-random ordering for each loaded app session and surface.
-
-The root loader creates the session seed, so SSR and hydration receive the same value through loader data. Client placement hooks read the root loader data directly and do not refresh it during normal navigation. Each surface combines its stable surface id with the session seed; avoid runtime-generated component ids in the seed because they can diverge across server and client rendering. Partners default to equal probability within their tier; a future `placementWeight` can bias same-tier odds without changing the ordering API.
-
-### `contextual-recommendation`
-
-Use when a surface behaves like a product recommendation or builder suggestion. Product fit should be established before commercial weighting affects order. This strategy currently preserves the legacy tier/priority behavior while giving recommendation surfaces a separate policy label.
-
-### `machine-readable`
-
-Use for AI- or crawler-facing outputs such as `llms.txt` and JSON feeds. These should be deterministic and relevance-first. Do not rotate them merely for logo fairness unless the downstream behavior has been designed and disclosed as such.
-
-## Reserved Rules
-
-Reserved rules should be narrow. For example, Cloudflare is reserved as the first deployment/hosting partner in any list that contains Cloudflare and other deployment partners. That should not imply Cloudflare is globally first across every partner surface; non-deployment partners can still appear before Cloudflare when the surface is mixed-category.
-
-Deployment action buttons are a deployment-only surface. Cloudflare remains first when present. Tier order is still preserved, and providers within the same tier can rotate by session.
-
-## Current Surfaces
-
-- Partner directory: `tier-rotated` for active partners, `static-curated` for previous partners.
-- Partner grids and embeds: `tier-rotated`.
-- Docs and blog rails: `tier-rotated`.
-- Mobile docs strip: `tier-rotated`.
-- Builder feature picker and starter partner suggestions: `contextual-recommendation`.
-- Deploy action buttons: `tier-rotated` with deployment provider tiers preserved.
-- `llms.txt` and `/api/data/partners`: `machine-readable`.
-
-## Analytics
-
-Partner impression and click events should include:
-
-- `partner_id`
-- `placement`
-- `slot_index`
-- `partner_tier`
-- `order_strategy`
-- `rotation_seed` when the strategy rotates
-
-This lets reporting distinguish partner performance from placement policy and makes under/over-exposure easier to debug.
-
-## Contract Language
-
-Suggested external framing:
-
-> Partner tiers determine eligibility, visual treatment, reporting, and relative access to surfaces. Placement within the same tier may rotate or be curated depending on the surface. Some placements may include explicitly reserved rules for product, infrastructure, legal, or strategic reasons.
-
-For AI-assisted selection:
-
-> AI-assisted partner selection prioritizes user need and capability fit first. Partner tier may influence selection only among qualified options.
diff --git a/docs/proposals/npm-watchlist-registry-draft.md b/docs/proposals/npm-watchlist-registry-draft.md
deleted file mode 100644
index 1e6a4f612..000000000
--- a/docs/proposals/npm-watchlist-registry-draft.md
+++ /dev/null
@@ -1,694 +0,0 @@
-# Draft: NPM Watchlist Registry
-
-## Purpose
-
-Define a source-of-truth registry for tracked npm entities, rollups, and curated watchlists.
-
-This registry should replace the idea that `popular comparisons` are the canonical list. Popular comparisons can still exist, but they should derive from this registry.
-
-Rollups should be the reusable grouping abstraction.
-
-Watchlists should remain the primary user-facing saved and subscribed surface. Categories are metadata on entities, not the source of truth for ranking surfaces.
-
-## Design Goals
-
-- Model logical libraries, not just raw package names.
-- Support legacy package rollups.
-- Support reusable rollup definitions for ecosystems and grouped chart views.
-- Keep curation explicit and reviewable in code.
-- Make it easy to derive chart `packageGroups` for the existing UI.
-- Leave room for future time-aware lineage rules without requiring them in v1.
-
-## Suggested File
-
-- `src/utils/npm-watchlists.ts`
-
-## Proposed Types
-
-```ts
-export type NpmTrackedEntity = {
-  id: string
-  label: string
-  shortLabel?: string
-  description?: string
-  categories: Array<
-    | 'tanstack'
-    | 'data-fetching'
-    | 'routing'
-    | 'state'
-    | 'table'
-    | 'form'
-    | 'virtualization'
-    | 'testing'
-    | 'styling'
-    | 'build'
-    | 'validation'
-    | 'docs'
-    | 'framework'
-    | 'animation'
-    | 'tooling'
-    | 'database'
-  >
-  color?: string
-  packages: Array<{
-    name: string
-    from?: string
-    to?: string
-  }>
-  lineageStrategy?: 'sum-all' | 'time-bounded'
-  benchmarkEligible?: boolean
-  popularComparisonEligible?: boolean
-  hidden?: boolean
-}
-
-export type NpmWatchlist = {
-  id: string
-  title: string
-  description?: string
-  kind: 'curated-category' | 'curated-benchmark' | 'curated-tanstack'
-  entityIds?: string[]
-  rollupIds?: string[]
-  featured?: boolean
-  public?: boolean
-  popularComparison?: boolean
-  benchmark?: boolean
-}
-
-export type NpmRollup = {
-  id: string
-  title: string
-  description?: string
-  kind: 'ecosystem' | 'category' | 'benchmark' | 'editorial'
-  entityIds: string[]
-  membershipMode?: 'exclusive' | 'overlap'
-  color?: string
-  public?: boolean
-}
-```
-
-## V1 Simplification
-
-Even though the entity type allows `from` and `to`, v1 can treat almost all tracked entities as:
-
-- `lineageStrategy: 'sum-all'`
-- all packages included for the full period
-
-That matches the current stats architecture and keeps the first implementation simple.
-
-## Derivations
-
-The registry should support these outputs:
-
-1. `getWatchlist(id)`
-2. `getTrackedEntity(id)`
-3. `getRollup(id)`
-4. `getFeaturedWatchlists()`
-5. `toPackageGroups(watchlist)` for existing chart routes
-6. `toPopularComparisons()` to replace hand-maintained duplication
-7. `groupEntitiesByRollup(entityIds, rollupIds)` for charting and digest summarization
-
-## Curation Rules
-
-### Tracked entities
-
-- One entity should represent one logical product or library line.
-- One entity can belong to multiple categories.
-- Legacy package names should be included when they clearly map to the same product lineage.
-- Do not merge adjacent but meaningfully different products just because users might compare them.
-
-### Watchlists
-
-- A curated category watchlist should feel category-coherent.
-- A benchmark watchlist should be broad, stable, and slow-changing.
-- A featured watchlist should be editorially maintained and reviewed regularly.
-
-### Rollups
-
-- A rollup is a reusable editorial grouping of entities.
-- Rollups are explicit, not inferred from npm scopes or package naming.
-- Some rollups can overlap.
-- Some rollups should be exclusive where double counting would distort rankings.
-- Rollups should be stable enough to support long-term historical views.
-
-### Review cadence
-
-- Featured watchlists: monthly review
-- Benchmark watchlists: quarterly review
-- Ecosystem rollups: quarterly review
-- Entity lineage changes: only when we have high confidence
-
-## Starter Tracked Entities
-
-This is not exhaustive. It is the first pass for v1.
-
-```ts
-export const trackedEntities: NpmTrackedEntity[] = [
-  {
-    id: 'tanstack-query',
-    label: 'TanStack Query',
-    categories: ['tanstack', 'data-fetching'],
-    color: '#FF4500',
-    packages: [{ name: '@tanstack/react-query' }, { name: 'react-query' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'swr',
-    label: 'SWR',
-    categories: ['data-fetching'],
-    color: '#ec4899',
-    packages: [{ name: 'swr' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'apollo-client',
-    label: 'Apollo Client',
-    categories: ['data-fetching'],
-    color: '#6B46C1',
-    packages: [{ name: '@apollo/client' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'trpc-client',
-    label: 'tRPC Client',
-    categories: ['data-fetching'],
-    color: '#2596BE',
-    packages: [{ name: '@trpc/client' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'tanstack-router',
-    label: 'TanStack Router',
-    categories: ['tanstack', 'routing'],
-    color: '#32CD32',
-    packages: [{ name: '@tanstack/react-router' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'react-router',
-    label: 'React Router',
-    categories: ['routing'],
-    color: '#FF0000',
-    packages: [{ name: 'react-router' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'wouter',
-    label: 'Wouter',
-    categories: ['routing'],
-    color: '#8b5cf6',
-    packages: [{ name: 'wouter' }],
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'tanstack-table',
-    label: 'TanStack Table',
-    categories: ['tanstack', 'table'],
-    color: '#FF7043',
-    packages: [{ name: '@tanstack/react-table' }, { name: 'react-table' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'ag-grid',
-    label: 'AG Grid',
-    categories: ['table'],
-    color: '#29B6F6',
-    packages: [{ name: 'ag-grid-community' }, { name: 'ag-grid-enterprise' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'mui-data-grid',
-    label: 'MUI Data Grid',
-    categories: ['table'],
-    color: '#1976D2',
-    packages: [{ name: '@mui/x-data-grid' }, { name: 'mui-datatables' }],
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'tanstack-form',
-    label: 'TanStack Form',
-    categories: ['tanstack', 'form'],
-    color: '#FFD700',
-    packages: [
-      { name: '@tanstack/form-core' },
-      { name: '@tanstack/react-form' },
-    ],
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'react-hook-form',
-    label: 'React Hook Form',
-    categories: ['form'],
-    color: '#EC5990',
-    packages: [{ name: 'react-hook-form' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'conform',
-    label: 'Conform',
-    categories: ['form'],
-    color: '#FF5733',
-    packages: [{ name: '@conform-to/dom' }],
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'tanstack-virtual',
-    label: 'TanStack Virtual',
-    categories: ['tanstack', 'virtualization'],
-    color: '#8B5CF6',
-    packages: [{ name: '@tanstack/react-virtual' }, { name: 'react-virtual' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'react-window',
-    label: 'react-window',
-    categories: ['virtualization'],
-    color: '#4ECDC4',
-    packages: [{ name: 'react-window' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'react-virtualized',
-    label: 'react-virtualized',
-    categories: ['virtualization'],
-    color: '#FF6B6B',
-    packages: [{ name: 'react-virtualized' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'redux',
-    label: 'Redux',
-    categories: ['state'],
-    color: '#764ABC',
-    packages: [{ name: 'redux' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'zustand',
-    label: 'Zustand',
-    categories: ['state'],
-    color: '#764ABC',
-    packages: [{ name: 'zustand' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'jotai',
-    label: 'Jotai',
-    categories: ['state'],
-    color: '#6366f1',
-    packages: [{ name: 'jotai' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'valtio',
-    label: 'Valtio',
-    categories: ['state'],
-    color: '#FF6B6B',
-    packages: [{ name: 'valtio' }],
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'vite',
-    label: 'Vite',
-    categories: ['build', 'tooling'],
-    color: '#008000',
-    packages: [{ name: 'vite' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'webpack',
-    label: 'Webpack',
-    categories: ['build', 'tooling'],
-    color: '#8DD6F9',
-    packages: [{ name: 'webpack' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'rollup',
-    label: 'Rollup',
-    categories: ['build', 'tooling'],
-    color: '#e80A3F',
-    packages: [{ name: 'rollup' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'esbuild',
-    label: 'esbuild',
-    categories: ['build', 'tooling'],
-    color: '#FFCF00',
-    packages: [{ name: 'esbuild' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'rspack',
-    label: 'Rspack',
-    categories: ['build', 'tooling'],
-    color: '#8DD6F9',
-    packages: [{ name: '@rspack/core' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'zod',
-    label: 'Zod',
-    categories: ['validation'],
-    color: '#ef4444',
-    packages: [{ name: 'zod' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'valibot',
-    label: 'Valibot',
-    categories: ['validation'],
-    color: '#f97316',
-    packages: [{ name: 'valibot' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'yup',
-    label: 'Yup',
-    categories: ['validation'],
-    color: '#06b6d4',
-    packages: [{ name: 'yup' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'react',
-    label: 'React',
-    categories: ['framework'],
-    color: '#61DAFB',
-    packages: [{ name: 'react' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'vue',
-    label: 'Vue',
-    categories: ['framework'],
-    color: '#41B883',
-    packages: [{ name: 'vue' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'angular-core',
-    label: 'Angular',
-    categories: ['framework'],
-    color: '#DD0031',
-    packages: [{ name: '@angular/core' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'svelte',
-    label: 'Svelte',
-    categories: ['framework'],
-    color: '#FF3E00',
-    packages: [{ name: 'svelte' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-  {
-    id: 'solid-js',
-    label: 'Solid',
-    categories: ['framework'],
-    color: '#2C4F7C',
-    packages: [{ name: 'solid-js' }],
-    benchmarkEligible: true,
-    popularComparisonEligible: true,
-  },
-]
-```
-
-## Starter Curated Watchlists
-
-```ts
-export const watchlists: NpmWatchlist[] = [
-  {
-    id: 'data-fetching',
-    title: 'Data Fetching',
-    kind: 'curated-category',
-    entityIds: ['tanstack-query', 'swr', 'apollo-client', 'trpc-client'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'routing-react',
-    title: 'Routing (React)',
-    kind: 'curated-category',
-    entityIds: ['react-router', 'tanstack-router', 'wouter'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'tables-data-grids',
-    title: 'Tables and Data Grids',
-    kind: 'curated-category',
-    entityIds: ['ag-grid', 'tanstack-table', 'mui-data-grid'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'forms',
-    title: 'Forms',
-    kind: 'curated-category',
-    entityIds: ['react-hook-form', 'tanstack-form', 'conform'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'virtualization',
-    title: 'Virtualization',
-    kind: 'curated-category',
-    entityIds: ['react-virtualized', 'react-window', 'tanstack-virtual'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'state-management',
-    title: 'State Management',
-    kind: 'curated-category',
-    entityIds: ['redux', 'zustand', 'jotai', 'valtio'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'build-tools',
-    title: 'Build Tools',
-    kind: 'curated-category',
-    entityIds: ['webpack', 'vite', 'rollup', 'esbuild', 'rspack'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'validation',
-    title: 'Validation',
-    kind: 'curated-category',
-    entityIds: ['zod', 'valibot', 'yup'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'frameworks',
-    title: 'Frameworks',
-    kind: 'curated-category',
-    entityIds: ['react', 'vue', 'angular-core', 'svelte', 'solid-js'],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'all-tanstack',
-    title: 'All TanStack Libraries',
-    kind: 'curated-tanstack',
-    entityIds: [
-      'tanstack-query',
-      'tanstack-router',
-      'tanstack-table',
-      'tanstack-form',
-      'tanstack-virtual',
-    ],
-    featured: true,
-    public: true,
-    popularComparison: true,
-  },
-  {
-    id: 'javascript-ecosystem-leaders',
-    title: 'JavaScript Ecosystem Leaders',
-    kind: 'curated-benchmark',
-    entityIds: [
-      'react',
-      'vue',
-      'angular-core',
-      'svelte',
-      'vite',
-      'webpack',
-      'rollup',
-      'esbuild',
-      'react-router',
-      'tanstack-query',
-      'apollo-client',
-      'redux',
-      'zustand',
-      'react-hook-form',
-      'zod',
-    ],
-    featured: true,
-    public: true,
-    benchmark: true,
-  },
-]
-```
-
-## Starter Rollups
-
-These are the new reusable grouping layer.
-
-```ts
-export const rollups: NpmRollup[] = [
-  {
-    id: 'tanstack-ecosystem',
-    title: 'TanStack Ecosystem',
-    kind: 'ecosystem',
-    entityIds: [
-      'tanstack-query',
-      'tanstack-router',
-      'tanstack-table',
-      'tanstack-form',
-      'tanstack-virtual',
-    ],
-    membershipMode: 'exclusive',
-    color: '#FF4500',
-    public: true,
-  },
-  {
-    id: 'data-fetching-ecosystem',
-    title: 'Data Fetching Ecosystem',
-    kind: 'category',
-    entityIds: ['tanstack-query', 'swr', 'apollo-client', 'trpc-client'],
-    membershipMode: 'overlap',
-    public: true,
-  },
-  {
-    id: 'router-ecosystem',
-    title: 'Router Ecosystem',
-    kind: 'category',
-    entityIds: ['react-router', 'tanstack-router', 'wouter'],
-    membershipMode: 'overlap',
-    public: true,
-  },
-  {
-    id: 'javascript-ecosystem-index',
-    title: 'JavaScript Ecosystem Index',
-    kind: 'benchmark',
-    entityIds: [
-      'react',
-      'vue',
-      'angular-core',
-      'svelte',
-      'vite',
-      'webpack',
-      'rollup',
-      'esbuild',
-      'react-router',
-      'tanstack-query',
-      'redux',
-      'react-hook-form',
-      'zod',
-    ],
-    membershipMode: 'overlap',
-    public: true,
-  },
-]
-```
-
-Future ecosystem rollups can include:
-
-- `vercel-ecosystem`
-- `remix-ecosystem`
-- `shopify-ecosystem`
-
-Those should be explicit editorial definitions, not guessed from npm scopes.
-
-## Notes On Specific TanStack Entities
-
-### Query
-
-- Roll up `react-query` into `@tanstack/react-query`.
-- This is a clear lineage case.
-
-### Table
-
-- Roll up `react-table` into `@tanstack/react-table`.
-- This is also a clear lineage case.
-
-### Virtual
-
-- Roll up `react-virtual` into `@tanstack/react-virtual`.
-- Clear lineage case.
-
-### Router
-
-- Do not automatically roll `react-location` into `@tanstack/react-router` in v1.
-- They are adjacent and related, but this one is more semantically debatable than Query, Table, or Virtual.
-- Keep this as an explicit later decision if we want lineage continuity there.
-
-### Form
-
-- It may be useful to define separate tracked entities for `@tanstack/form-core` and `@tanstack/react-form` later.
-- For now, a single `TanStack Form` entity is probably fine if the goal is product-level visibility.
-
-## Migration From Existing Popular Comparisons
-
-Recommended path:
-
-1. Create the entity, rollup, and watchlist registry.
-2. Rebuild `getPopularComparisons()` from curated watchlists and rollups marked for that purpose.
-3. Keep route behavior and `packageGroup` format unchanged at first.
-4. Add optional rollup grouping to chart UI later.
-5. Remove duplication once parity looks good.
-
-## Open Decisions
-
-1. How aggressive should we be about rolling up framework adapters into one product-level TanStack entity?
-2. Which rollups should be exclusive versus overlap-friendly?
-3. Should benchmark lists include only `benchmarkEligible` entities or also allow manual exceptions?
-4. Should `JavaScript Ecosystem Leaders` aim for 25, 50, or 100 entities in v1?
-5. Which watchlists and rollups should be exposed publicly on day one versus internal-only at first?
-
-## Immediate Next Steps
-
-- Convert this draft into a real `src/utils/npm-watchlists.ts` module.
-- Add real rollup definitions for ecosystem and benchmark views.
-- Fill out the tracked entity registry beyond the starter set.
-- Rebuild stale popular comparisons from the registry.
-- Draft the first larger benchmark roster separately.
diff --git a/docs/proposals/npm-watchlists-and-weekly-digests.md b/docs/proposals/npm-watchlists-and-weekly-digests.md
deleted file mode 100644
index 9abc6e304..000000000
--- a/docs/proposals/npm-watchlists-and-weekly-digests.md
+++ /dev/null
@@ -1,665 +0,0 @@
-# Proposal: NPM Watchlists and Weekly Digests
-
-## North Star
-
-Turn TanStack NPM Stats into a durable market map for JavaScript libraries, not just a charting tool.
-
-Users should be able to follow a set of libraries, understand who is actually gaining or losing ground inside that set, and get a concise weekly digest that surfaces meaningful movement without reacting to noisy daily swings.
-
-## Why This Matters
-
-- Raw npm download charts are useful, but they do not answer "who is winning?"
-- Raw download growth is increasingly misleading because the whole ecosystem keeps growing.
-- Users care about cohorts, rankings, trend changes, and share shifts more than isolated download counts.
-- TanStack already has fast comparison UX and historical npm data. This feature turns that into a repeatable product.
-
-## Product Outcome
-
-Add rollups and watchlists that users can explore, compare, and subscribe to by email once a week.
-
-Each digest should answer:
-
-- Which libraries moved up or down?
-- Which libraries gained or lost share?
-- Which crossovers happened?
-- Which trends look sustained instead of noisy?
-
-## Core Product Principles
-
-- `packages -> entities -> rollups -> watchlists` should be the core model.
-- Watchlists are the user-facing saved and subscribed surface.
-- Rollups are reusable editorial groupings for charts, rankings, and ecosystem views.
-- Popular comparisons are just one derived view, not the source of truth.
-- Rankings should be cohort-aware, not framed as vague "global npm rank".
-- Normalization should favor share and relative outperformance, not raw download growth.
-- Long-term rollups must work across legacy package names and package renames.
-- Weekly summaries should highlight meaningful movement, not short-term volatility.
-
-## User Stories
-
-1. As a user, I can subscribe to a curated watchlist like `Data Fetching` or `Build Tools`.
-2. As a user, I can save a custom watchlist from the current `/stats/npm` comparison.
-3. As a user, I can follow a logical library even if its npm history spans multiple package names.
-4. As a user, I can receive a weekly digest showing rank changes, share changes, and notable crossovers.
-5. As a user, I can look back across months or years and see how the watchlist evolved.
-6. As a user, I can compare larger ecosystem rollups like `TanStack` vs `Vercel` vs `Remix`.
-7. As a user, I can plot entities grouped by rollup on charts.
-
-## Product Shape
-
-### 1. Tracked entities
-
-The atomic unit should not be a single npm package. It should be a logical tracked entity.
-
-Example:
-
-```ts
-{
-  id: 'tanstack-query',
-  label: 'TanStack Query',
-  packages: ['@tanstack/react-query', 'react-query'],
-}
-```
-
-This gives us:
-
-- continuity across legacy/current package names
-- stable ranking entities
-- cleaner digest language
-- long-term rollups without losing historical identity
-
-### 2. Rollups
-
-A rollup is a reusable grouped view of tracked entities.
-
-Examples:
-
-- `tanstack-ecosystem`
-- `vercel-ecosystem`
-- `remix-ecosystem`
-- `data-fetching-ecosystem`
-- `router-ecosystem`
-
-Rollups let us:
-
-- compare larger ecosystems on charts
-- group entities visually inside one watchlist
-- create reusable market-map views without copying entity membership everywhere
-- support ecosystem-vs-ecosystem reporting in digests later
-
-### 3. Watchlists
-
-A watchlist is a named saved surface for ranking, digesting, and charting.
-
-A watchlist can contain:
-
-- entities directly
-- rollups
-- or both, depending on the UX we want
-
-Recommended watchlist types:
-
-- Curated category watchlists
-- Curated benchmark watchlists
-- TanStack-specific watchlists
-- Ecosystem watchlists
-- User-created custom watchlists
-
-### 4. Weekly digest
-
-Each watchlist can produce a weekly digest with:
-
-- current rank
-- previous rank
-- rank delta
-- share of watchlist
-- share delta
-- trailing 4-week trend
-- notable crossovers
-- link back to the live chart
-
-Future digest expansions:
-
-- rollup-vs-rollup share changes
-- entity movement inside a rollup
-- ecosystem momentum summaries
-
-## Current Model Draft
-
-This is the current preferred abstraction stack:
-
-- `package`: raw npm package history
-- `entity`: one logical library or product across one or more packages
-- `rollup`: reusable editorial grouping of entities
-- `watchlist`: a saved and subscribable surface for charts, rankings, and digests
-
-Current preferred type direction:
-
-```ts
-export type NpmTrackedEntity = {
-  id: string
-  label: string
-  shortLabel?: string
-  description?: string
-  categories: Array<
-    | 'tanstack'
-    | 'data-fetching'
-    | 'routing'
-    | 'state'
-    | 'table'
-    | 'form'
-    | 'virtualization'
-    | 'testing'
-    | 'styling'
-    | 'build'
-    | 'validation'
-    | 'docs'
-    | 'framework'
-    | 'animation'
-    | 'tooling'
-    | 'database'
-  >
-  color?: string
-  packages: Array<{
-    name: string
-    from?: string
-    to?: string
-  }>
-  lineageStrategy?: 'sum-all' | 'time-bounded'
-  benchmarkEligible?: boolean
-  popularComparisonEligible?: boolean
-  hidden?: boolean
-}
-
-export type NpmRollup = {
-  id: string
-  title: string
-  description?: string
-  kind: 'ecosystem' | 'category' | 'benchmark' | 'editorial'
-  entityIds: string[]
-  membershipMode?: 'exclusive' | 'overlap'
-  color?: string
-  public?: boolean
-}
-
-export type NpmWatchlist = {
-  id: string
-  title: string
-  description?: string
-  kind: 'curated-category' | 'curated-benchmark' | 'curated-tanstack'
-  entityIds?: string[]
-  rollupIds?: string[]
-  featured?: boolean
-  public?: boolean
-  popularComparison?: boolean
-  benchmark?: boolean
-}
-```
-
-Important modeling notes:
-
-- categories belong on entities as `categories: string[]`, not a single `category`
-- watchlists are still the primary curated product surface
-- rollups are the reusable grouping abstraction
-- popular comparisons should be derived from the registry, not hand-maintained independently
-- v1 can mostly use `lineageStrategy: 'sum-all'`
-- the model should still leave room for future time-bounded lineage
-
-## Current Decisions
-
-These are the main decisions made so far and should be preserved in any handoff.
-
-- Do not frame the feature as "global npm rank" in v1.
-- Rank within a defined cohort, watchlist, or benchmark basket.
-- Use trailing 28-day downloads for ranking.
-- Use share of watchlist as the primary normalization lens.
-- Use relative growth vs watchlist median as a secondary lens.
-- Defer anomaly detection.
-- Prefer conservative digest-oriented trend signals over loose anomaly alerts.
-- Build curated entities and watchlists separately from current `popular comparisons`.
-- Include legacy package rollups where lineage is clear.
-- Preserve compatibility with long-term and multi-year rollups.
-- Treat rollups as first-class because ecosystem-vs-ecosystem views are valuable.
-- Allow entities to belong to multiple categories.
-- Allow some rollups to overlap and some to be exclusive.
-
-## Lineage Decisions So Far
-
-These are the explicit lineage calls already discussed.
-
-- Roll up `react-query` into `@tanstack/react-query`.
-- Roll up `react-table` into `@tanstack/react-table`.
-- Roll up `react-virtual` into `@tanstack/react-virtual`.
-- Do not automatically roll `react-location` into `@tanstack/react-router` in v1.
-- TanStack Form may eventually need separate entity treatment for `@tanstack/form-core` and `@tanstack/react-form`, but one product-level entity is acceptable for v1.
-
-## Starter Registry Direction
-
-These are the concrete examples already drafted and should be treated as the current starting point, not final production data.
-
-Starter entities:
-
-- `tanstack-query`: `@tanstack/react-query` + `react-query`
-- `swr`: `swr`
-- `apollo-client`: `@apollo/client`
-- `trpc-client`: `@trpc/client`
-- `tanstack-router`: `@tanstack/react-router`
-- `react-router`: `react-router`
-- `wouter`: `wouter`
-- `tanstack-table`: `@tanstack/react-table` + `react-table`
-- `ag-grid`: `ag-grid-community` + `ag-grid-enterprise`
-- `mui-data-grid`: `@mui/x-data-grid` + `mui-datatables`
-- `tanstack-form`: `@tanstack/form-core` + `@tanstack/react-form`
-- `react-hook-form`: `react-hook-form`
-- `conform`: `@conform-to/dom`
-- `tanstack-virtual`: `@tanstack/react-virtual` + `react-virtual`
-- `react-window`: `react-window`
-- `react-virtualized`: `react-virtualized`
-- `redux`: `redux`
-- `zustand`: `zustand`
-- `jotai`: `jotai`
-- `valtio`: `valtio`
-- `vite`: `vite`
-- `webpack`: `webpack`
-- `rollup`: `rollup`
-- `esbuild`: `esbuild`
-- `rspack`: `@rspack/core`
-- `zod`: `zod`
-- `valibot`: `valibot`
-- `yup`: `yup`
-- `react`: `react`
-- `vue`: `vue`
-- `angular-core`: `@angular/core`
-- `svelte`: `svelte`
-- `solid-js`: `solid-js`
-
-Starter watchlists:
-
-- `data-fetching`
-- `routing-react`
-- `tables-data-grids`
-- `forms`
-- `virtualization`
-- `state-management`
-- `build-tools`
-- `validation`
-- `frameworks`
-- `all-tanstack`
-- `javascript-ecosystem-leaders`
-
-Starter rollups:
-
-- `tanstack-ecosystem`
-- `data-fetching-ecosystem`
-- `router-ecosystem`
-- `javascript-ecosystem-index`
-
-Future rollups already discussed:
-
-- `vercel-ecosystem`
-- `remix-ecosystem`
-- `shopify-ecosystem`
-
-## Rollup Semantics
-
-Rollups should be explicit editorial definitions, not inferred automatically from npm scopes or package naming.
-
-Two intended rollup modes:
-
-- `exclusive`: best for aggregate comparisons where double counting would distort the view
-- `overlap`: best for discovery, category views, and flexible grouped charting
-
-Examples:
-
-- `tanstack-ecosystem` should likely be `exclusive`
-- category-style rollups like `router-ecosystem` can be `overlap`
-
-## Popular Comparisons Migration
-
-The current `popular comparisons` file is useful but not a good source of truth.
-
-Migration direction:
-
-1. Build the entity, rollup, and watchlist registry.
-2. Rebuild `getPopularComparisons()` from curated watchlists and rollups flagged for chart use.
-3. Keep the existing route behavior and `packageGroup` output shape unchanged at first.
-4. Add rollup-aware chart grouping later.
-5. Remove duplicated manual comparison definitions once parity looks good.
-
-## Handoff Notes
-
-If another agent picks this up, these are the highest-value next steps already implied by the current plan.
-
-1. Convert the draft model into a real `src/utils/npm-watchlists.ts` module.
-2. Expand the entity registry beyond the starter examples.
-3. Define the first real ecosystem rollups.
-4. Decide the first public benchmark basket, especially `JavaScript Ecosystem Leaders`.
-5. Replace stale `popular comparisons` with registry-derived definitions.
-6. Design the DB schema around entities, rollups, watchlists, subscriptions, and weekly snapshots.
-
-## Registry Strategy
-
-### Entities, rollups, and watchlists should become a first-class registry
-
-Current `popular comparisons` are useful, but they are not sufficient as the long-term source of truth.
-
-Problems with using them directly:
-
-- some are out of date
-- some are sized for chart exploration, not ongoing tracking
-- some do not include legacy package rollups
-- some are too small or too editorial for ranking semantics
-
-We should create a separate curated registry for entities, rollups, and watchlists and let `popular comparisons` derive from it where useful.
-
-Suggested source file:
-
-- `src/utils/npm-watchlists.ts`
-
-### Why rollups matter
-
-Rollups are what let this feature graduate from simple package comparison into a real market map.
-
-Examples:
-
-- compare `TanStack` vs `Vercel` vs `Remix`
-- plot all router-related entities and color them by ecosystem rollup
-- compare a product rollup against its category peers
-- track how an ecosystem's aggregate share changes over time
-
-### Starter watchlist categories
-
-Curated category watchlists:
-
-- Data Fetching
-- Routing
-- Tables and Data Grids
-- Forms
-- State Management
-- Virtualization
-- Testing
-- Styling
-- Build Tools
-- Validation and Schema
-- Motion and Animation
-- Meta-frameworks
-
-Curated benchmark watchlists:
-
-- JavaScript Ecosystem Leaders
-- React Ecosystem Leaders
-- JavaScript Infra Index
-- Frontend Foundation 50
-
-TanStack watchlists:
-
-- All TanStack Libraries
-- TanStack Query Ecosystem
-- TanStack Router Ecosystem
-- TanStack Table Ecosystem
-
-Ecosystem rollups and watchlists:
-
-- TanStack Ecosystem
-- Vercel Ecosystem
-- Remix Ecosystem
-- Router Ecosystem
-- Data Fetching Ecosystem
-
-## Legacy Package Rules
-
-Legacy packages should usually roll into the same tracked entity when they represent the same product lineage.
-
-Examples:
-
-- `react-query` + `@tanstack/react-query`
-- `react-table` + `@tanstack/react-table`
-- `react-virtual` + `@tanstack/react-virtual`
-
-Do not assume every rename or adjacent product belongs in the same lineage forever. The model should allow future time-aware lineage rules if simple summing becomes misleading.
-
-V1 rule:
-
-- tracked entities use a flat array of package names and simple summed downloads
-
-Future rule if needed:
-
-- tracked entities can support time-bounded package membership
-
-## Ranking and Normalization
-
-Raw download growth is not a good primary signal because the entire ecosystem is growing.
-
-### Primary ranking metric
-
-Use trailing 28-day downloads for rank within a watchlist.
-
-Why 28-day:
-
-- smoother than daily or weekly
-- still responsive enough for weekly digests
-- less sensitive to npm reporting noise
-
-### Primary normalization metric: share of watchlist
-
-```txt
-share_of_watchlist = entity_28d_downloads / sum(all_watchlist_entity_28d_downloads)
-```
-
-This should be the main lens in digests because it makes leaders and laggards visible even when the whole cohort is growing.
-
-### Secondary normalization metric: relative growth vs cohort
-
-```txt
-relative_growth = entity_growth_rate - median_growth_rate_of_watchlist
-```
-
-This helps answer whether a library is outperforming or underperforming peers.
-
-### Optional tertiary benchmark: ecosystem index
-
-Do not use a raw sum of giant packages as the main baseline.
-
-If we add an ecosystem benchmark, it should come from a stable curated basket like `JavaScript Ecosystem Leaders` and use a median or trimmed-mean growth factor rather than raw weighted sum.
-
-### Digest metrics to show in v1
-
-- rank
-- rank delta
-- share of watchlist
-- share delta
-- trailing 4-week trend
-- crossover events
-
-## Trend Detection
-
-Avoid generic "anomaly detection" in v1.
-
-The data is too noisy and npm has known reporting weirdness. We already correct obvious outliers and zero-day anomalies in the stats pipeline.
-
-If we surface trend change signals, they should be conservative and digest-oriented.
-
-Suggested v1 notable movement rules:
-
-- rank changed by at least 1
-- share moved by a meaningful threshold
-- one entity crossed another and stayed ahead for at least one full digest period
-- growth outperformed the cohort median by a meaningful margin
-
-Possible later signals:
-
-- sustained acceleration over 4 weeks vs prior 8 to 12 weeks
-- sustained deceleration over 4 weeks vs prior 8 to 12 weeks
-- held rank breakout after 2 consecutive weeks
-
-## Long-Term Rollups
-
-This feature must support multi-year rollups.
-
-The current architecture is already favorable:
-
-- npm history is cached as year-based daily chunks
-- historical chunks are immutable
-- package groups already support rollups across multiple packages
-
-What we need to preserve:
-
-- tracked entities, not single-package rows
-- reusable rollups on top of tracked entities
-- raw historical chunks as source of truth
-- derived weekly or monthly snapshots for cheap leaderboard queries
-
-Recommended derived data:
-
-- weekly watchlist snapshots for digest generation and rank history
-- optional monthly snapshots for faster long-range reporting
-
-This keeps the system flexible:
-
-- raw chunks allow recomputation when formulas change
-- snapshots keep digests and rank-history queries cheap
-- rollups can be recalculated without changing the raw source data
-
-### Rollup views to support later
-
-- yearly rollup trend lines
-- rank history by entity within a rollup
-- ecosystem-vs-ecosystem comparisons
-- grouped charts where entities are colored or faceted by rollup
-
-## UX Entry Points
-
-### `/stats/npm`
-
-- `Save this comparison`
-- `Follow this watchlist`
-- `Subscribe to weekly digest`
-
-### Library-specific npm stats pages
-
-- `Follow this library`
-- `Add to watchlist`
-
-### Account area
-
-- My watchlists
-- Subscriptions
-- Digest frequency
-- Pause or unsubscribe
-
-## Data Model Direction
-
-Suggested tables:
-
-- `npm_tracked_entities`
-- `npm_tracked_entity_packages`
-- `npm_rollups`
-- `npm_rollup_entities`
-- `npm_watchlists`
-- `npm_watchlist_items`
-- `npm_watchlist_subscriptions`
-- `npm_watchlist_weekly_snapshots`
-- `npm_digest_sends`
-
-Notes:
-
-- tracked entities are the canonical source for rollups and labels
-- rollups reference tracked entities
-- watchlists can reference tracked entities, rollups, or both
-- weekly snapshots are required for stable digest generation and historical ranking views
-- digest send records help with idempotency and auditability
-
-## Implementation Phases
-
-### Phase 1. Curated registry
-
-- Define tracked entities, rollups, and curated watchlists in code
-- Include legacy package rollups where appropriate
-- Refresh and replace stale `popular comparisons`
-- Establish one larger benchmark watchlist like `JavaScript Ecosystem Leaders`
-
-### Phase 2. Snapshot pipeline
-
-- Compute weekly watchlist snapshots from historical npm data
-- Store rank, share, and trailing trend fields
-- Make snapshot generation idempotent
-
-### Phase 3. User-facing watchlists
-
-- Allow signed-in users to save comparisons as custom watchlists
-- Allow subscription management from account surfaces
-- Allow subscription from stats pages
-
-### Phase 4. Weekly digests
-
-- Generate digest content from weekly snapshots
-- Send through Resend
-- Include unsubscribe and pause controls
-
-### Phase 5. Trend signals and benchmark refinement
-
-- Add conservative trend-change detection
-- Add ecosystem index if the curated benchmark proves stable and useful
-
-## Suggested Implementation Order
-
-1. Build the curated tracked-entity and watchlist registry.
-2. Add reusable rollups for ecosystems and category-level groupings.
-3. Replace or derive `popular comparisons` from the new registry.
-4. Add database tables for tracked entities, rollups, watchlists, subscriptions, and weekly snapshots.
-5. Build the weekly snapshot job on top of existing historical npm chunks.
-6. Expose watchlists and rollup grouping in UI without email first.
-7. Add weekly digest generation and delivery.
-8. Add rank history and long-range watchlist views.
-9. Add conservative trend signals only after real snapshot data exists.
-
-## Success Criteria
-
-- Users can subscribe to curated watchlists and custom watchlists.
-- Users can compare major ecosystem rollups clearly.
-- Digests clearly show rank movement and share movement.
-- Rankings are stable enough to feel trustworthy week to week.
-- Legacy package history rolls up cleanly into one logical entity.
-- Multi-year watchlist history remains queryable and understandable.
-- We can explain the ranking math in plain English.
-
-## Non-Goals For V1
-
-- claiming true global npm rank across the whole registry
-- real-time alerts
-- daily digest spam
-- loose anomaly detection with low confidence
-- overfitting the system to every one-off package rename
-
-## Open Questions
-
-1. Which curated watchlists should ship first?
-2. How large should `JavaScript Ecosystem Leaders` be?
-3. Which legacy package transitions deserve permanent lineage rollups?
-4. Which rollups should be exclusive versus allowed to overlap?
-5. Should custom watchlists allow raw package groups, tracked entities, rollups, or all three?
-6. Should digests be weekly only in v1, or should monthly be supported from day one?
-7. Do we want one digest per watchlist or one combined digest across all subscriptions?
-
-## Progress
-
-- [ ] Define tracked entity model
-- [ ] Define rollup model
-- [ ] Draft curated watchlist registry
-- [ ] Draft curated ecosystem rollups
-- [ ] Audit and refresh current popular comparisons
-- [ ] Define lineage rules for legacy package rollups
-- [ ] Design DB schema
-- [ ] Build weekly snapshot job
-- [ ] Build watchlist UI surfaces
-- [ ] Build subscription management
-- [ ] Build weekly digest generation
-- [ ] Build email delivery and unsubscribe flow
-- [ ] Add rank history views
-- [ ] Evaluate trend signals after snapshot data accumulates
-
-## Notes
-
-- Existing npm stats infrastructure already gives us a strong foundation: historical daily chunks, package grouping, outlier correction, and email transport.
-- The biggest risk is product semantics, not raw implementation difficulty.
-- The feature will feel credible only if watchlists are curated well and ranking math stays easy to explain.
diff --git a/docs/proposals/source-packages.md b/docs/proposals/source-packages.md
deleted file mode 100644
index 5c4a41685..000000000
--- a/docs/proposals/source-packages.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# Proposal: `-source` Companion Packages for AI Code Exploration
-
-## Problem
-
-AI agents (Claude, Cursor, Copilot, etc.) work best when they can grep and read actual source code. Our npm packages only contain built output (transpiled JS, type definitions), not the original TypeScript source.
-
-Current workarounds:
-
-- Clone repos to `/tmp` manually
-- Ask the agent to clone and explore
-- Read minified/transpiled code (poor results)
-
-These are clunky and break the flow of AI-assisted development.
-
-## Proposal
-
-Publish companion `-source` packages alongside every TanStack library release:
-
-```bash
-# Normal install (built output, what runs in production)
-npm install @tanstack/react-query@5.62.0
-
-# Source install for AI exploration (dev only)
-npm install @tanstack/react-query-source@5.62.0 --save-dev
-```
-
-The `-source` package contains the `src/` directory from the monorepo package (TypeScript and JavaScript source). It's not meant to be imported at runtime, only explored by AI agents.
-
-## Structure
-
-```
-node_modules/@tanstack/react-query-source/
-  src/
-    useQuery.ts
-    useMutation.ts
-    QueryClient.ts
-    ...
-  package.json
-```
-
-## MCP Integration
-
-The hosted MCP server gets a simple tool:
-
-```typescript
-get_source_instructions({ library: 'query' })
-```
-
-Returns:
-
-```json
-{
-  "package": "@tanstack/react-query-source",
-  "install": "npm install @tanstack/react-query-source@5.62.0 --save-dev",
-  "explorePath": "node_modules/@tanstack/react-query-source/src/",
-  "note": "Install this package to explore TanStack Query source code. Use your file tools to grep and read."
-}
-```
-
-The agent installs the package, then uses its native file system tools (grep, read) to explore. No custom MCP tooling for search/read needed.
-
-## Implementation
-
-### 1. CI/CD Changes
-
-On every release, publish two packages:
-
-```yaml
-# Pseudocode
-- name: Publish main package
-  run: npm publish
-
-- name: Publish source package
-  run: |
-    # Create temp package with only src/
-    cp -r src/ dist-source/src/
-    cp package.json dist-source/
-    # Modify package name to add -source suffix
-    jq '.name += "-source"' dist-source/package.json > tmp && mv tmp dist-source/package.json
-    # Modify files field to only include src/
-    jq '.files = ["src"]' dist-source/package.json > tmp && mv tmp dist-source/package.json
-    cd dist-source && npm publish
-```
-
-### 2. MCP Server Changes
-
-Add one tool to the hosted MCP:
-
-- `get_source_instructions` - Returns install command and explore path for a library
-
-### 3. Documentation
-
-Update MCP docs to explain the `-source` package convention.
-
-## Benefits
-
-1. **No local CLI needed** - Hosted MCP only, agents handle the rest
-2. **Standard npm workflow** - No new tooling for developers to learn
-3. **Version alignment** - Source version always matches installed version
-4. **Works with any AI agent** - Just needs file system access
-5. **Minimal overhead** - Source packages are small (just source files)
-
-## Tradeoffs
-
-1. **Double publish** - Every release publishes 2 packages per library
-2. **npm registry usage** - More packages, though source packages are small
-3. **Agent must run npm install** - One extra step, but agents already know how
-
-## Alternatives Considered
-
-### Local CLI that caches repos
-
-Rejected: Adds complexity, requires users to install a CLI, duplicates what npm already does.
-
-### Hosted grep API (Sourcegraph-backed)
-
-Rejected: External dependency, rate limits, adds context overhead for every query.
-
-### Include source in main package
-
-Rejected: Bloats production installs, source isn't needed at runtime.
-
-### GitHub raw content proxy via MCP
-
-Rejected: Can't grep across files, need to know exact paths upfront.
-
-## Open Questions
-
-1. Should we include related packages? (e.g., `@tanstack/query-core` source in `@tanstack/react-query-source`)
-2. What about monorepo-level files like shared utilities?
-3. Should we include tests in the source package?
-
-## Next Steps
-
-1. Prototype the publish workflow for one library (react-query)
-2. Test with Claude/Cursor to validate the agent experience
-3. Roll out to all TanStack libraries
-4. Add MCP tool and documentation
diff --git a/docs/source-code-audit-2026-06-23.md b/docs/source-code-audit-2026-06-23.md
deleted file mode 100644
index cd5da7ed0..000000000
--- a/docs/source-code-audit-2026-06-23.md
+++ /dev/null
@@ -1,2392 +0,0 @@
-# Source Code Audit - 2026-06-23
-
-Status: living audit report. Scope covers the source inventory, static scans, full `src/routes` pass, `src/libraries` metadata, `src/styles`, API/server-function boundaries, auth/OAuth repositories and flows, builder endpoints, builder client/deploy UI, application-starter paths, `src/components` domain passes including landing, shop, game, stats, docs, admin/community, SearchModal/AiDock, LibraryLayout/docs chrome, partner/sponsor, stack, CodeExplorer/FileExplorer, query/hooks/client data helpers, MCP transport/tools, CLI auth tickets, scheduled tasks, shared cache/db/runtime/Sentry/env helpers, UI primitives, image helpers, contexts/stores, Vite/Start/test config, scripts, and existing tests. Excludes `node_modules`, generated route tree findings, blog prose findings, images/assets, and packaged starter template files.
-
-The repo is large: roughly 145k hand-written TS/TSX lines under `src`, plus tests and scripts, with the largest files concentrated in search, navigation/layout, stats, builder, docs, admin/community flows, partners, and the game.
-
-## Merged Fix Order
-
-This is ordered by easy or quick work that also makes later fixes safer. Severity still overrides the queue when needed, so a P1 can jump the line, but this is the default sequence for batching.
-
-Tracking model: work this list top to bottom. Leave untouched bullets plain, prefix the active one with `[doing]`, completed ones with `[done YYYY-MM-DD: verification]`, and skipped/deferred ones with `[defer: reason]`. Add one short entry to the batch log when a batch lands.
-
-### 1. Small shared guardrails
-
-These are quick, high-leverage patches because later fixes can reuse them instead of adding another local workaround.
-
-- [done 2026-06-24: `pnpm run test:unit`, `pnpm test`] Make `pnpm test` honest about TypeScript tests, or split script names so validation expectations are clear.
-- [done 2026-06-24: `pnpm run test:tsc`, `pnpm run test:lint`] Default non-submit buttons/select triggers to `type="button"`, fix the shared pagination label/id contract, and clean up tooltip child prop merging.
-- [done 2026-06-24: `pnpm run test:unit`, `pnpm run test:tsc`, `pnpm run test:lint`] Add tiny helpers for response filenames, package slug encoding, and row-local ids.
-- [done 2026-06-24: `pnpm test`, 60-route local smoke] Add tiny helpers for external URL normalization, internal route classification, and guarded storage.
-- [done 2026-06-24: `pnpm test`, 60-route local smoke] Share low-risk clipboard/copy-state timers and auth popup centering.
-- Fix custom `useMutation` stale callbacks, docs sidebar timers, remaining popup blocked-state handling, and remaining page-visibility/reduced-motion call sites.
-- [done 2026-06-24: `pnpm test`, 60-route local smoke] Normalize maintainer bare-domain URLs, add Scarf/social URL contract tests, and centralize the public-library selector.
-- Normalize partner analytics seed buckets, blog image URLs, and host cache purge response policy.
-
-### 2. One-off cleanup that lowers noise
-
-Do these early when they're obvious and isolated, but don't let them block higher-leverage guardrails.
-
-- Fix Twoslash invalid CSS, duplicate shop fonts, shop `NEW` badge duration, app-starter dead selection parameter, app-starter schema caveat, Netlify pending state, mobile merch loading, sponsor tooltip math, Stack category fallbacks, and unsupported admin timestamp formatting.
-- Remove or quarantine dead code once confirmed: old logo copies, unused account feedback component, showcase helper duplicates, unused shop promo components, deprecated `reactChartsProject`, and the old R3F scene/dependencies if we're ready to delete them.
-- Lazy-load obvious bundle leaks with low behavior risk: Start/Router landing prompt resolver and navbar merch product loading.
-
-### 3. Fast security and public-boundary hardening
-
-These are small enough to do before deeper refactors and they reduce risk while larger helpers are being designed.
-
-- [done 2026-06-24: `pnpm test:tsc`, `pnpm test:lint`, `pnpm test`] Analytics proxy header allowlist, GitHub webhook fail-closed secret, Cloudflare-first IP extraction, route response filename sanitizing, Discord raw-body size/signature-shape guard, MCP transport content-type/length guard and masked errors.
-- [done 2026-06-24: `pnpm test:tsc`, `pnpm test:lint`, `pnpm test`] Public pagination and route-search caps, admin pagination/range caps, docs feedback content caps, showcase field/array caps, Shopify page-size/quantity/discount caps, UploadThing client preflight caps, image transform clamps, community-resource frontmatter schema.
-- [done 2026-06-24: `pnpm test:tsc`, `pnpm test:lint`, `pnpm test`] URL/link hardening: Intent metadata URLs, Intent tarball source paths, markdown/source/search link classification, legacy redirect segment matching, local docs path containment.
-- [done 2026-06-24: `pnpm test:tsc`, `pnpm test:lint`, `pnpm test`] OAuth/login quick fixes: return-to key mismatch, login modal stale callbacks, provider route param picklists, session revocation conflict retry, and session base64 fallback removal once covered.
-
-### 4. Small controllers that prevent repeat bugs
-
-These are still bounded, but they should come before domain cleanup so every domain fix uses the same shape.
-
-- Route-scoped pagination controller with request fingerprints for shop, search, admin lists, stats, and any accumulated-page UI.
-- Query-key completeness helper or lint pattern for related showcases, current-user data, npm recent downloads, GitHub stats, and cache field preservation.
-- Optimistic mutation controller for showcase voting, feedback updates, keyed row actions, and cart/update flows.
-- Browser effect toolkit for storage, timers, outside-click, drag/resize, clipboard, popup polling, window-open, and page visibility.
-- Product option/color helper for shop cards, drawers, product pages, swatches, variant resolution, and preload budgets.
-
-### 5. Domain hardening with shared primitives in place
-
-At this point the repeatable helpers exist, so these PRs should be smaller and more reliable than fixing every route in isolation.
-
-- Stats: public npm fan-out caps, stats UI bounds, partial recent-cache semantics, baseline math, scheduled 429 backoff/deadlines, admin cache budgets, chart range caps, and d3/date-helper split.
-- Builder/application starter: selected-framework validation, URL/store feature parsing, custom template/add-on contract, ecommerce template preservation, response parsing, transient feedback, nested feature-card links, and API endpoint guard alignment.
-- Showcase/feedback: concurrent first-vote handling, query keys, submission/rank caps, moderation notification handling, detail/admin error states, audit target ids, leaderboard SQL aggregation, docs feedback cache/update helper.
-- Shop: Storefront caps, trusted HTML policy, image transform helper, load-more fingerprints, variant option resolution, quick-view modal semantics, nested product-card interactions, preload budgets, cart optimistic shape, and mutation-backed success state.
-- Admin: capability validators, all-table role/user queries, stale bulk selections, keyed row actions, invalidation scope, dashboard lazy tabs, route/server guard sync, and transaction boundaries where the write surface is small.
-- Docs/markdown: frontmatter resilience, raw HTML/iframe trust policy, tab state parsing/recovery, remote docs config caps, recursive-tree truncation handling, and manifest/cache budgets.
-- Game: progression/restored counts, compass/AI island sets, NaN guards, health flash, persistence ownership, async disposal checks, culling children, and geometry ownership.
-
-### 6. Larger boundary refactors
-
-These need design notes first, but the earlier guardrails should make the migration less risky.
-
-- One builder API request/response contract across compile, validate, download, deploy, remote loads, feature artifacts, suggestions, MCP, and client generation.
-- One outbound fetch policy with timeout, status, content-type, response-size, redirect, and header rules for OAuth, GitHub, docs, Tranco, stats, Intent, Shopify, remote builder loads, and scripts.
-- Intent ingestion redesign: remove public-read ingestion, add tarball budgets, failed-version backoff/dead-letter state, transactional skill replacement, admin process/list caps, complete GitHub discovery, and safe source paths.
-- Auth/OAuth storage model: atomic authorization-code consumption, dynamic client registration persistence, client-id to redirect-uri binding, OAuth user/account upsert transaction, plaintext token migration.
-- Docs manifest/cache system: recursive tree completeness, file-count/concurrency budgets, redirect metadata caching, stale fallback completeness, local path guards, and cache admin budgets.
-- MCP and CLI auth: durable CLI tickets, public-create limits, rate-limit uniqueness by identifier type, cleanup by window size, API-key lifecycle caps, and lower `lastUsedAt` write amplification.
-- Search/AI and LibraryLayout: split expensive imports, dock/modal state, Kapa/chat persistence, source-link routing, docs nav/tabs/mobile/sponsors/feedback, and route data boundaries.
-- Partner/sponsor/catalog and shop storefront boundaries: split serializable metadata from assets/JSX, centralize public catalog visibility, remove sponsor duplication, and unify shop product/cart contracts.
-- Environment/observability/runtime: consolidate env readers, Sentry PII/sample-rate policy, production diagnostics behavior, database context proxy, and script runtime budgets.
-
-### 7. Product-wide initiatives and upstream candidates
-
-Treat these as tracked initiatives, not normal cleanup PRs.
-
-- Product landing shell and library landing route factory.
-- UI primitive consolidation across root and shop.
-- Route/module boundary cleanup and shared validation schema system.
-- Admin table/filter framework and docs feedback DOM primitive.
-- Deploy-dialog controller and browser effect toolkit.
-- Bundle-boundary program with reachability checks, global-shell lazy panels, partner asset split, AI/search split, d3/Plot splits, navbar merch lazy import, and old R3F dependency removal.
-- Upstream candidates: TanStack Start API-boundary helper, outbound fetch helper, OAuth PKCE/code-consumption helper, Drizzle transaction/audit helper, route search hydration helper, endpoint request/response schema helper, package slug codec, docs manifest builder, npm download chunk cache, Shopify image helper.
-- First AI skills to actually build: API hardening, outbound fetch hygiene, route param boundaries, query-key completeness, type-safety sweep, bundle hotspot splitting, UI primitive form defaults, and route reachability/dead-stack cleanup.
-
-## Batch Log
-
-- 2026-06-24: Landed the grouped security pass across public request boundaries, OAuth/login/session flows, builder/GitHub deploy APIs, outbound fetches, stats/Intent/docs/shop/showcase caps, UploadThing/image preflight, markdown/search link classification, docs redirects/path containment, and community-resource frontmatter validation.
-- 2026-06-24: Second pass simplified the security patch by sharing bounded body readers, npm package-name/page/date/chart schemas, and URL normalization; removed the local application-starter request guard while preserving same-origin/content-type/body-size enforcement.
-- 2026-06-24: Added shared response filename/content-disposition helpers, package route slug encoding helpers, and row-local id helpers; wired them into builder/docs downloads, Intent registry links, and moderation note inputs.
-- 2026-06-24: Hardened shared UI primitives: defaulted shop buttons and shared select triggers to non-submit buttons, gave pagination a unique page-size label/id pair plus non-submit controls, and made Tooltip merge trigger handlers/refs without `any`.
-- 2026-06-24: Added `test:unit` and wired it into `pnpm test` so existing TypeScript assertion tests run in the default validation path.
-- 2026-06-24: Added guarded browser storage/effect helpers, migrated low-risk clipboard/timer/popup/storage/reduced-motion call sites, centralized public-library selection, normalized maintainer URLs, and added static data contract tests.
-- 2026-06-23: Audit created and ordered.
-
-## Highest Priority Findings
-
-### P0/P1 - Analytics proxy forwards private request headers
-
-`src/server.ts:85-112` proxies same-origin analytics paths to Google and passes `headers: request.headers` directly at `src/server.ts:101-104`.
-
-Same-origin browser requests can include cookies such as session cookies and may include authorization-like headers. Forwarding the whole inbound header bag to Google is unnecessary and risky.
-
-Suggested fix:
-
-- Build a fresh `Headers` allowlist for analytics upstream requests.
-- Never forward `cookie`, `authorization`, `host`, `cf-*`, `x-forwarded-*`, or internal headers.
-- Consider allowing only `accept`, `accept-language`, `user-agent`, and content headers needed by collect POSTs.
-
-### P1 - OAuth authorization codes are not consumed atomically
-
-`src/auth/oauthClient.server.ts:168-238` reads an authorization code, validates expiry/redirect/PKCE, deletes it at `src/auth/oauthClient.server.ts:205-208`, then inserts access and refresh tokens.
-
-Two concurrent exchanges can read and validate the same code before either delete wins. Both can mint tokens. The schema has a unique `codeHash`, but that only prevents duplicate codes, not concurrent reuse.
-
-Suggested fix:
-
-- Use a transaction and atomically delete/claim by `codeHash` plus validity conditions, returning the row.
-- Alternatively add a `consumedAt` field and update `where consumedAt is null returning`.
-- Only mint tokens after a single request has proven it owns the code.
-
-### P1 - OAuth client IDs are not bound to registered redirect URIs
-
-`src/routes/oauth/register.ts:28-80` accepts dynamic client metadata, validates submitted redirect URIs, then returns a deterministic client id from `client_name` at `src/routes/oauth/register.ts:113-127`. The registration is not stored. Later, `src/routes/oauth/authorize.tsx:38-93` and `src/utils/oauthClient.functions.ts:48-79` only validate that the requested redirect URI is localhost or HTTPS; they do not verify that the `client_id` owns that redirect URI.
-
-The authorize UI displays `Authorize {displayClientId}` at `src/routes/oauth/authorize.tsx:241-254` but does not show the redirect origin. A crafted authorization link can therefore use a familiar-looking client id with a different HTTPS redirect URI.
-
-Suggested fix:
-
-- Store dynamic client registrations with redirect URI allowlists, client display names, and created timestamps.
-- Require authorize/token flows to match `client_id + redirect_uri` against the stored registration.
-- Add client-name, redirect-uri count, redirect-uri length, state, scope, and PKCE length caps.
-- Show the redirect origin/app name in the consent screen.
-
-### P1 - Client IP extraction trusts spoofable headers before Cloudflare
-
-`src/utils/request.server.ts:21-34` checks `x-forwarded-for`, then `x-real-ip`, then `cf-connecting-ip`.
-
-On Cloudflare-hosted traffic, `cf-connecting-ip` should be the trusted source. Taking `x-forwarded-for` first can let clients spoof IP identity for rate limits, audit/logins, MCP limits, and any future IP-based guard.
-
-Suggested fix:
-
-- Prefer `cf-connecting-ip` on Cloudflare.
-- Only trust `x-forwarded-for` from known proxy paths.
-- Normalize and validate IP shape before using it as a rate-limit key.
-
-### P1 - Public npm stats requests can cause unbounded fan-out
-
-`src/utils/stats-queries.functions.ts:12-33` uses typed pass-through validators for public bulk npm stats input. `src/utils/stats.server.ts:470-646` turns those values into per-package, per-range chunk requests and runs missing chunks with `Promise.all` at `src/utils/stats.server.ts:549-646`.
-
-The stats UI route schemas do not cap package counts, string length, or date span tightly enough. Examples include `src/routes/stats/npm/-comparisons.ts:3-13`, `src/routes/stats/npm/-utils.ts:44-47`, `src/routes/stats/npm/index.tsx:57-86`, and `src/routes/stats/npm/$packages.tsx:15-23`. A public request can fan out to many npm API requests, Blob storage listings, and DB cache operations.
-
-`src/utils/npm-download-cache.server.ts:368-390` also lists all Blob objects under a prefix with page size `1000` but no page/object cap. That is called from latest-chunk lookups at `src/utils/npm-download-cache.server.ts:560-607` and `src/utils/npm-download-cache.server.ts:636-691`, so a large package set can turn into many unbounded storage-list operations.
-
-The DB fallback has the same scaling shape. `src/utils/stats-db.server.ts:1347-1382` and `src/utils/stats-db.server.ts:1422-1459` build one `or(...)` predicate per requested chunk, so uncapped input can create a huge SQL predicate before npm fetches even start. The newer Blob helper also runs storage/cache reads with unbounded `Promise.all` at `src/utils/npm-download-cache.server.ts:506-555`, `src/utils/npm-download-cache.server.ts:560-631`, and `src/utils/npm-download-cache.server.ts:636-715`.
-
-The authenticated MCP npm stats tool caps `packages` at 10 in `src/mcp/tools/npm-stats.ts:19-43`, but package/library/preset strings are still loose and flow into the same stats code at `src/mcp/tools/npm-stats.ts:163-176`.
-
-Suggested fix:
-
-- Add a shared runtime schema for npm stats queries.
-- Cap package group count, packages per group, package name length/pattern, and max date window.
-- Add concurrency limiting for npm fetches.
-- Cap cache batch sizes and split large chunk lookups into bounded pages.
-- Add page/object caps to npm download-cache Blob listings.
-- Add per-IP or per-session limits for public bulk stats calls.
-
-### P2 - NPM stats UI search state is unbounded too
-
-The backend fan-out issue is mirrored at the route/search layer. `src/routes/stats/npm/index.tsx:57-86` and `src/routes/_library/$libraryId/$version.docs.npm-stats.tsx:66-93` accept `packageGroups` arrays through URL search without max lengths, and `height` is just `v.number()` with no min/max. The slug redirect route parses unlimited package lists from `params.packages` at `src/routes/stats/npm/$packages.tsx:24-27` via `src/routes/stats/npm/-utils.ts:43-47`, then turns them into search `packageGroups` at `src/routes/stats/npm/$packages.tsx:91-101`. The shared `packageGroupSchema` at `src/routes/stats/npm/-comparisons.ts:3-13` also accepts arbitrary package names, color strings, and baseline labels.
-
-Result: a crafted stats URL can create a huge query string, oversized React Query key, large server-function payload, and a pathological chart container height before backend guards have a chance to help.
-
-Suggested fix: move npm stats route/search schemas into `src/components/npm-stats/shared.ts` or a dedicated `src/utils/npm-stats-schema.ts`, cap total packages/groups, validate npm package names, trim labels/colors, and clamp chart height to a sane range.
-
-### P2 - NPM stats baseline normalization can corrupt relative change
-
-`src/components/npm-stats/NPMStatsChart.tsx:219-233` captures `firstDownloads` before baseline normalization, then mutates each point's `d.downloads` in place when `normalizeByBaseline` is active. The returned `change` value subtracts the raw first download count from the normalized current value.
-
-That means the "Relative Change" transform can be wrong whenever baseline normalization is active: the y-value mixes two units. The in-place mutation also makes this block harder to reason about because the original binned point object is no longer raw after the first normalized pass.
-
-Suggested fix: compute `normalizedDownloads` as a local value, compute `firstNormalizedDownloads` with the same divisor policy, and return a fresh `{ ...d, downloads: normalizedDownloads, change: normalizedDownloads - firstNormalizedDownloads }` object without mutating the binned point.
-
-### P2 - Recent npm stats can return partial totals from partial cache hits
-
-`src/utils/npm-download-cache.server.ts:636-717` returns a map of the package names that have a covering cached chunk; it is intentionally partial when some packages miss both Blob and legacy cache. `src/utils/stats.server.ts:974-1023` treats `latestCachedChunks.size > 0` as a complete hit and returns aggregate daily/weekly/monthly totals using only the cached chunks.
-
-For a library with multiple packages, one cached package and one missing package can undercount all recent download totals while still looking fresh.
-
-Suggested fix: only take the fast path when `latestCachedChunks.size === packageNames.length`, or merge cached package results with bounded fetches for the missing package names before returning.
-
-### P1/P2 - Scheduled NPM stats refresh can hang forever on 429s
-
-The scheduled all-time package fetch loops until a chunk succeeds. `src/utils/stats.functions.ts:266-355` uses `while (!success)` and, for npm `429`, waits five seconds and retries the same chunk with no attempt cap, wall-clock deadline, or abort signal. `fetchSingleNpmPackageFresh` adds outer retries at `src/utils/stats.functions.ts:375-435`, but the inner 429 loop can prevent those retries from ever advancing.
-
-`computeNpmOrgStats` then runs package refreshes through an `AsyncQueuer` at `src/utils/stats.functions.ts:517-569`, so one stuck package chunk can hold the whole org refresh. The admin trigger reaches this path through `src/utils/stats-admin.server.ts:367-375`.
-
-Suggested fix: add a bounded retry policy for npm chunk fetches, use `AbortSignal.timeout`/a task deadline, record partial failures explicitly, and let the scheduled refresh finish with a degraded result instead of waiting indefinitely.
-
-### P1 - Builder API endpoints do not share request guards
-
-`src/routes/api/builder/compile.ts:6-32`, `compile-attributed.ts`, `validate.ts`, `suggest.ts`, `feature-artifacts.ts`, `download.ts`, `load-template.ts`, `load-remote-template.ts`, and `load-remote-addon.ts` read JSON/query input directly and do not share one request guard/schema. Some apply the builder rate-limit preset, but content-type, content-length, same-origin, filename, and response-error policy are still inconsistent.
-
-`RATE_LIMITS.builderCompile` exists in `src/utils/rateLimit.server.ts`, and `src/routes/api/application-starter/resolve.ts:80-166` already shows a stronger pattern with rate limits, body guards, content-type checks, same-origin checks, schema parsing, and cache headers.
-
-Specific risks:
-
-- `src/routes/api/builder/compile.ts:8-22` reads arbitrary JSON and only checks that `definition` exists.
-- `src/routes/api/builder/download.ts:21-88` accepts raw query values and uses raw `name` for `zip.folder(name)` and `Content-Disposition`.
-- Error responses expose internal exception messages in several builder endpoints.
-
-Suggested fix:
-
-- Create a shared builder request schema and parse every entrypoint through it.
-- Apply `RATE_LIMITS.builderCompile` or a more specific preset.
-- Add content-type, content-length, and same-origin guards.
-- Sanitize project names and response filenames.
-- Return stable public error codes and log internal details separately.
-
-### P1 - Remote builder loads have allowlist checks but no fetch budget
-
-`src/builder/api/remote.ts:101-104` fetches remote template JSON with no timeout, status check, content-type check, or response-size limit. `src/builder/api/remote.ts:125-127` delegates remote add-ons to `loadRemoteAddOn`.
-
-`src/utils/url-validation.server.ts` is a good SSRF/host allowlist, but allowed CDN URLs can still hang workers or return huge responses. The validation also happens before `fetch`; `src/builder/api/remote.ts:101-103` uses the default redirect-following behavior and does not revalidate the final response URL, so an allowed host redirect can bypass the initial host check.
-
-Suggested fix:
-
-- Add a central `fetchJsonWithLimit` helper with `AbortSignal.timeout`, byte cap, content-type guard, status guard, and JSON parse errors.
-- Use `redirect: 'manual'` or revalidate every redirect/final URL against the same allowlist.
-- Use it for remote templates and any remote add-on path that allows user-provided URLs.
-
-### P1 - GitHub docs webhook fails open when the secret is unset
-
-`src/routes/api/github/webhook.ts:67-85` only verifies the GitHub signature when `env.GITHUB_WEBHOOK_SECRET` is present. If the secret is missing, any public POST can send a watched repo/ref payload, mark docs/content cache rows stale at `src/routes/api/github/webhook.ts:136-139`, and trigger cache purge at `src/routes/api/github/webhook.ts:151`.
-
-Suggested fix:
-
-- Fail closed in production if `GITHUB_WEBHOOK_SECRET` is missing.
-- Return a clear deploy/config error instead of accepting unsigned webhooks.
-- Add a content-length cap before `request.text()`.
-- Consider validating event id/delivery headers for replay diagnostics.
-
-### P1 - Intent package detail performs npm ingestion on the public request path
-
-`src/utils/intent.functions.ts:422-435` accepts any string package name and, when the package is missing locally, calls `inlineSeedPackage`. That path fetches npm metadata, downloads the latest tarball, extracts skills, writes package/version rows, and stores skill content at `src/utils/intent.functions.ts:304-396`.
-
-This turns an unauthenticated read endpoint into a package-ingestion worker. It also uses an unbounded in-memory `rejectedPackages` map keyed by arbitrary request strings at `src/utils/intent.functions.ts:247-260`.
-
-Suggested fix:
-
-- Validate package names with a shared npm package-name schema and length cap.
-- Do not process unknown packages inline from public detail requests.
-- Queue unknown packages for bounded background processing, or require admin/manual seeding.
-- Add per-IP/session rate limits for public Intent registry misses.
-- Cap or replace the local rejection cache with bounded cache infrastructure.
-
-### P1 - Intent tarball extraction has no time or size budget
-
-`src/utils/intent.server.ts:293-358` fetches a package tarball with no timeout, compressed-size cap, decompressed-size cap, entry-count cap, or per-file cap. Matching `SKILL.md` files are buffered fully at `src/utils/intent.server.ts:336-340`.
-
-Any npm package that reaches Intent discovery, admin seeding, scheduled queue processing, or the public inline-seed path can force large stream work. The scheduled GitHub discovery path also extracts a tarball inline before enqueueing at `src/server/scheduled.server.ts:230-331`, so the expensive tarball path is not isolated to the bounded queue processor.
-
-Suggested fix:
-
-- Fetch tarballs with an abort timeout and content-length cap.
-- Enforce max decompressed bytes, max tar entries, max skill files, and max bytes per `SKILL.md`.
-- Abort the pipeline as soon as any cap is exceeded.
-- Store the caps in one reusable ingestion policy object.
-
-### P1/P2 - Public Intent registry queries lack request caps
-
-`src/utils/intent.functions.ts:104-240` accepts uncapped `search`, `framework`, `page`, and `pageSize`, then may call npm search and fetch versions/skills for every verified package. The default npm discovery helper also pages until npm's reported total ends at `src/utils/intent.server.ts:89-111`, with no page cap or deadline. `src/utils/intent.functions.ts:595-600` accepts uncapped skill-search limits. `src/utils/intent.functions.ts:697-735` accepts an uncapped `packageNames` array and fans out through `Promise.all`. The package-history paths have the same shape: `src/utils/intent.functions.ts:799-828` accepts an uncapped changelog `limit`, and `src/utils/intent.functions.ts:966-1007` walks every version for one skill with no limit. The package route also accepts unbounded `expanded` and `expandedSkills` URL arrays at `src/routes/intent/registry/$packageName.tsx:53-58`, then feeds them into sets and URL writes at `src/routes/intent/registry/$packageName.index.tsx:80-101` and `src/routes/intent/registry/$packageName.index.tsx:163-177`.
-
-Suggested fix:
-
-- Add shared public Intent query schemas with max query length, page size, page number, package count, package-name length, and history limit.
-- Prefer precomputed directory rows for list pages instead of per-package version/skill lookups in the request path.
-- Apply bounded concurrency where fan-out remains necessary.
-
-### P3 - Intent dependency graph uses a fixed SVG marker id
-
-`src/components/intent/SkillDependencyGraph.tsx:168-176` defines ``, and every dependency line references `markerEnd="url(#arrowhead)"` at `src/components/intent/SkillDependencyGraph.tsx:208-218`. If two dependency graphs ever render on the same page, the marker id collides across SVGs and references can resolve to the first definition.
-
-Suggested fix: generate the marker id with `React.useId()` and reference `url(#${markerId})`, or accept a stable id prefix from the parent if the graph needs deterministic screenshots.
-
-### P2/P3 - Intent package metadata URLs are rendered without URL normalization
-
-Intent package detail pages render package metadata from npm. `src/utils/intent.functions.ts:273-284` builds `repositoryUrl` from `latestMeta.repository` by stripping `git+` and `.git`, but does not parse the URL or restrict protocols. Directory rows also pass through npm-provided homepage/repository/npm links at `src/utils/intent.functions.ts:198-204`.
-
-The package layout renders `detail.repositoryUrl` directly as an external anchor at `src/routes/intent/registry/$packageName.tsx:257-264`. A package's npm metadata can contain non-web protocols or malformed values, and the UI still titles the link "GitHub".
-
-Suggested fix: normalize npm metadata URLs through a shared `normalizeExternalPackageUrl` helper that accepts only `https:` and `http:` for public anchors, converts common `git+https`/`git@github.com:` repository forms to web URLs, drops anything else, and stores a `repositoryHost`/label separately from the href.
-
-### P2/P3 - Intent skill source links trust tarball path segments
-
-`src/utils/intent.server.ts:319-334` accepts any tar entry matching `package/skills/**/SKILL.md` and derives `skillPath` by string replacement. It does not reject `..`, encoded separators, duplicate slashes, or odd path segments. The skill detail page then interpolates that value into an unpkg Source link at `src/routes/intent/registry/$packageName.$skillName.tsx:143-147`.
-
-The link is external, not a local file read, so this is lower risk than extraction traversal. It still lets package-controlled tar headers produce malformed or misleading source URLs on a trusted registry page.
-
-Suggested fix: parse tar entry names into normalized path segments before storing `skillPath`, reject traversal/empty segments, and build the unpkg URL with `new URL()` plus per-segment encoding.
-
-### P3 - NPM-style package slugs are not round-trip safe
-
-`decodePkgName` encodes scoped package names by replacing `/` with `__` at `src/routes/intent/registry/$packageName.tsx:29-33`. Directory links use the inverse shape with `pkg.name.replace('/', '__')` at `src/routes/intent/registry/index.tsx:556-563`, `src/routes/intent/registry/index.tsx:573-575`, and `src/routes/intent/registry/index.tsx:653-663`.
-
-The same codec exists for NPM stats at `src/routes/stats/npm/-utils.ts:34-40`, with parsed params used by `src/routes/stats/npm/$packages.tsx:24-26`.
-
-That delimiter is undocumented and ambiguous for any package name that already contains `__`; the route would decode the first delimiter into `/` and look up a different package. Suggested fix: use `encodeURIComponent`/`decodeURIComponent` for route params, or centralize an explicit package-name slug codec with tests for scoped, unscoped, underscore, and malformed names across Intent and stats routes.
-
-### P3 - Maintainer social links rely on raw static URL strings
-
-Maintainer social profile data is typed as loose strings at `src/libraries/maintainers.ts:15-20`, then rendered directly as anchor hrefs by `src/components/MaintainerCard.tsx:183-200`. One current entry already misses a protocol: `src/libraries/maintainers.ts:316-319` sets `website: 'harry-whorlow.dev'`, which renders as a relative site URL instead of an external profile link.
-
-Suggested fix: validate static maintainer data at module load/build time or normalize it through the same external URL helper used for package metadata. The helper should either add `https://` for known bare domains or reject malformed profile URLs loudly.
-
-## Correctness And Stability Findings
-
-### P2 - Game persistence runs from module scope
-
-`src/components/game/hooks/useGameStore.ts:48-53` casts parsed `localStorage` JSON to `PersistedState` without runtime validation. `src/components/game/hooks/useGameStore.ts:1000-1016` starts an interval and `beforeunload` listener at module scope when `window` exists.
-
-This is easy to duplicate under HMR and hard to clean up. Move persistence into a mounted component/hook with teardown and schema validation.
-
-### P2 - Game engine async initialization can attach work after disposal
-
-`src/components/game/scene/VanillaGameScene.tsx:64-80` creates a `GameEngine`, calls async `engine.init()`, and disposes the engine on unmount. `GameEngine.start()` guards `isDisposed` at `src/components/game/engine/GameEngine.ts:700-710`, but `GameEngine.init()` itself continues after asset preload at `src/components/game/engine/GameEngine.ts:172-217` and can add scene objects, store subscriptions, and `BoatControlSystem` listeners after disposal.
-
-The engine has the same issue after setup. `src/components/game/engine/GameEngine.ts:391-421` and `src/components/game/engine/GameEngine.ts:576-606` fetch showcase data and then mutate store/entity/ocean state from `.then(...)` callbacks without checking `this.isDisposed`. `src/components/game/engine/entities/Islands.ts:1034-1050` loads partner logo textures and adds meshes in the `TextureLoader.load` callback without knowing whether the island/info group has already been disposed. `src/components/game/engine/GameEngine.ts:674-687` also schedules an untracked `setTimeout` inside a store subscription. If the engine is disposed before any of those callbacks fire, stale work can still spawn AI or update disposed scene objects.
-
-Smaller UI timer examples:
-
-- `src/components/game/ui/BadgeOverlay.tsx:112-115` schedules dismiss work without cleanup.
-- `src/components/game/ui/TouchControls.tsx:4-10` uses module-scope click debounce state and an untracked timeout.
-
-Suggested fix:
-
-- Add an abort/cancel guard to `GameEngine.init()` after every awaited preload step.
-- Track and clear engine-owned timeouts in `dispose()`.
-- Avoid adding systems/subscriptions/listeners after `isDisposed` is true.
-- Consider making `init()` return a cleanup-capable task or accepting an `AbortSignal`.
-
-### P3 - Game discovery confetti never uses island colors
-
-`src/components/game/engine/entities/Islands.ts:933-949` creates the flag material with the island color but never stores that color on `flag.userData`. `spawnConfetti` reads `instance.flagGroup.userData.color || '#FFD700'` at `src/components/game/engine/entities/Islands.ts:1389-1396`, so every island falls back to gold-accent confetti instead of deriving its palette from the discovered library/partner/showcase.
-
-Suggested fix: set `flag.userData.color = color` when the flag is created, or pass the island color through the `IslandInstance` data instead of userData.
-
-### P2/P3 - Game island culling misses world-space children
-
-`src/components/game/engine/entities/Islands.ts:1262-1268` distance-culls only `instance.group`. Several visible children are intentionally added directly to the top-level `Islands.group` instead of `instance.group`: lobe wave rings at `src/components/game/engine/entities/Islands.ts:253-273`, flags at `src/components/game/engine/entities/Islands.ts:422-431`, info cards at `src/components/game/engine/entities/Islands.ts:433-437`, and main wave rings at `src/components/game/engine/entities/Islands.ts:439-462`.
-
-The update loop skips wave-ring animation when `instance.group.visible` is false at `src/components/game/engine/entities/Islands.ts:1369-1375`, but it never hides those meshes. Distant islands can therefore still render their world-space rings/flags/cards while the land mass is culled. Suggested fix: add a per-instance world-space group for rings/flag/info and cull it together, or explicitly update visibility for every detached child.
-
-### P3 - Island flag position ignores the rotation it computes
-
-`calculateFlagWorldPosition` computes rotated local offsets at `src/components/game/engine/entities/Islands.ts:963-972`, but returns unrotated `signOffsetX` and `signOffsetZ` at `src/components/game/engine/entities/Islands.ts:974-978`. Rotated islands therefore place flags at the same world offset instead of the rotated local flag anchor. Suggested fix: return `data.position[0] + localX * data.scale` and `data.position[2] + localZ * data.scale`, with any intentional fixed nudge named separately.
-
-### P2 - Progression machine checks completion before counting the current discovery
-
-The progression machine guards compare current context counts against totals at `src/components/game/machines/progressionMachine.ts:117-128`, but the transition actions increment only after the guard is evaluated. The discovery transitions repeat that shape for libraries, partners, showcases, and corners at `src/components/game/machines/progressionMachine.ts:259-265`, `src/components/game/machines/progressionMachine.ts:288-294`, `src/components/game/machines/progressionMachine.ts:304-310`, and `src/components/game/machines/progressionMachine.ts:320-326`.
-
-The sync path sends exactly one event for the newly discovered island at `src/components/game/machines/useProgressionSync.ts:34-67` after `discoverIsland` adds the id to the store at `src/components/game/hooks/useGameStore.ts:399-415`. If the machine has `total - 1` discoveries and the player discovers the last island, the guard sees the old count, takes the non-transition branch, increments to `total`, and then waits for another discovery event that may never happen. Zustand still unlocks stages directly at `src/components/game/hooks/useGameStore.ts:421-489`, so UI/gameplay and badge machine state can diverge.
-
-Suggested fix: make the completion guard include the incoming discovery (`context.count + 1 >= total`), or increment first and use `always` transitions to check completion from updated context. Add regression tests for `1/1` and `N/N` completions per phase.
-
-### P2 - Restored game progress misclassifies core library islands
-
-`GameMachineProvider` restores machine counts from `discoveredIslands` by checking id prefixes at `src/components/game/machines/GameMachineProvider.tsx:96-127`: `library-`, `partner-`, `showcase-`, and `corner-`. Core library islands are generated with the raw library id at `src/components/game/utils/islandGenerator.ts:162-168`, not a `library-*` prefix, so restored `librariesDiscovered` can stay at `0` after reload even when the player has discovered Query/Table/Router/etc.
-
-That can leave badge/progression machine state behind the zustand gameplay state. Suggested fix: classify discoveries from the actual island collections, or share the same island-type lookup used by live discovery sync instead of inferring type from string prefixes.
-
-### P2 - Ocean rock regeneration reuses disposed shared geometry
-
-`OceanRocks` creates one shared `dodecahedronGeo` at `src/components/game/engine/entities/OceanRocks.ts:35-38`. `generate()` clears the previous rock set by calling `dispose()` at `src/components/game/engine/entities/OceanRocks.ts:50-53`, and `dispose()` disposes each `rock.rockMesh.geometry` at `src/components/game/engine/entities/OceanRocks.ts:210-217`. Those meshes all point at the shared `dodecahedronGeo`, but `generate()` then immediately creates new rock meshes with the same disposed geometry at `src/components/game/engine/entities/OceanRocks.ts:105-120`.
-
-Today `GameEngine.initializeGameData()` calls `generate()` once at `src/components/game/engine/GameEngine.ts:336-357`, but the method is written as a reusable regeneration API and will break under HMR, reset/regenerate features, or any future world refresh.
-
-Suggested fix: treat `dodecahedronGeo` like an owner-level resource and dispose it only from a final `dispose()` path, or recreate it after clearing generated groups. Per-rock cleanup should dispose only per-rock materials and cached ring geometries that are not reused.
-
-### P2 - Game model cleanup disposes cached/shared geometry
-
-`modelLoader.clone()` clones the cached scene at `src/components/game/engine/loaders/ModelLoader.ts:43-50` and clones only the material at `src/components/game/engine/loaders/ModelLoader.ts:56-58`. Geometry stays shared with the cached GLTF. `Boat.dispose()` then traverses the current boat model and disposes `child.geometry` at `src/components/game/engine/entities/Boat.ts:291-298`, and `AIShips.disposeShip()` does the same at `src/components/game/engine/entities/AIShips.ts:146-154`.
-
-That means disposing one boat/AI clone can dispose geometry still owned by the loader cache or by other clones. `AIShips` has the same problem for its own cannon geometries: `createShip()` uses shared `this.cannonGeometries.base/barrel` at `src/components/game/engine/entities/AIShips.ts:49-64`, but `disposeShip()` disposes each child geometry when one AI ship is removed. The next ship can render with a disposed shared cannon geometry.
-
-There is also an opposite leak in the same area: `Boat.setBoatType()` removes the old model from the group at `src/components/game/engine/entities/Boat.ts:75-83` but does not dispose its cloned materials before replacing it.
-
-Suggested fix: make ownership explicit. If model geometry is shared through `ModelLoader`, instance cleanup should dispose only cloned materials/textures, not geometry. Shared cannon geometry should only be disposed in `AIShips.dispose()`. If per-instance geometry is required, deep-clone geometry at creation and keep the disposal contract local.
-
-### P2 - Health damage flash can retrigger forever after one hit
-
-`GameHUD` tracks `prevHealth` separately from `boatHealth` at `src/components/game/ui/GameHUD.tsx:44-45`. The damage-flash effect detects a drop at `src/components/game/ui/GameHUD.tsx:106-114`, sets `damageFlash`, and returns without updating `prevHealth`. After the 150ms timer clears `damageFlash`, `boatHealth` is still lower than the stale `prevHealth`, so the effect can schedule another flash for the same old hit. The bar can keep flashing until health is restored or another path updates `prevHealth`.
-
-Suggested fix: update `prevHealth` whenever a new `boatHealth` value is observed, including the damage branch. A simple pattern is to compare against the previous value in a functional state update, trigger the flash as a side effect of the comparison, and always store the current health for the next render.
-
-### P2/P3 - Game generated vectors can become NaN or infinite
-
-`createAICannonball` computes a lead target at `src/components/game/engine/systems/AISystem.ts:314-316` and divides by `leadDist` at `src/components/game/engine/systems/AISystem.ts:318-319` without a zero guard. If the target point equals the cannon fire point after spread/lead math, the cannonball velocity becomes `NaN`, and that value is written into the shared cannonball array.
-
-Island placement has the same shape. `generateIslands` pushes islands apart by dividing by `dist` at `src/components/game/utils/islandGenerator.ts:107-113`, and the expanded/showcase generators repeat it at `src/components/game/utils/islandGenerator.ts:315-324` and `src/components/game/utils/islandGenerator.ts:417-425`. Duplicate generated positions are unlikely, but if they happen the world coordinates can become `Infinity`/`NaN` and then flow into collisions, minimap, ocean gradients, and Three transforms.
-
-Suggested fix: centralize a `safeNormalize2D(dx, dz, fallbackAngle)` helper for AI fire, collision push, and generator push-apart math. When distance is zero or non-finite, use a deterministic fallback vector from the seed/id instead of dividing.
-
-### P3 - Compass shop item ignores showcase and corner islands
-
-The active boat control system correctly includes `showcaseIslands` and `cornerIslands` in late-game collision/nearby detection at `src/components/game/engine/systems/BoatControlSystem.ts:152-156`. The compass purchase path does not: `purchaseItem('compass')` builds `allIslands` from only `islands` and `expandedIslands` at `src/components/game/hooks/useGameStore.ts:787-797`.
-
-After showcase or corner islands unlock, a bought compass can only target core library or partner islands, so it stops helping with the current progression layer. Suggested fix: extract one `getReachableIslands(state)` helper and use it in controls, compass targeting, restart spawn selection, minimap, and counters.
-
-The AI system has the same split in a different behavior. `src/components/game/engine/systems/AISystem.ts:409-424` builds obstacle-avoidance islands from only `islands` and `expandedIslands`, then uses that list at `src/components/game/engine/systems/AISystem.ts:554-565`. Outer-rim and boss ships can therefore ignore showcase/corner island collision pressure even though the player collides with those islands. Include AI avoidance in the shared reachable-islands helper, or expose separate `getCollidableIslands(state)` / `getCompassTargetableIslands(state)` helpers if the sets intentionally differ.
-
-### P2 - Custom `useMutation` has stale callbacks and casts through redirects
-
-`src/hooks/useMutation.ts:29-53` only depends on `opts.fn`, while using `opts.onSuccess`, `opts.onError`, and `opts.onSettled`. Changed callbacks can go stale. The catch path does not await `onSettled` even though the type allows a promise. `src/hooks/useMutation.ts:59` and `src/hooks/useMutation.ts:86-88` use casts.
-
-Consider replacing this with `@tanstack/react-query` mutation helpers or tighten this hook with refs/deps and typed redirect handling.
-
-### P2 - Tooltip clone drops/overrides child behavior
-
-`src/components/Tooltip.tsx:43-47` clones the child with a new `ref` and `getReferenceProps()` but does not merge the child ref or pass existing child props into `getReferenceProps`.
-
-Use the Floating UI pattern: `getReferenceProps(children.props)` and a merged ref utility.
-
-### P2/P3 - Button and select primitives rely on native submit defaults
-
-`src/ui/Button.tsx:129-144` creates the requested element and forwards props, but when `as` is omitted it renders a native `