mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15:06 +03:00
merge master into danny/depr-632-eslint-outline-none
Resolve conflicts from the merged focus-ring long-tail (DEPR-628) by taking master for shared UI/docs/www call sites. Keep the ESLint rule, ratchet, and baseline changes for DEPR-632.
This commit is contained in:
commit
564c64fd2c
1186 files changed
+28658
-12445
No files matched your search
@@ -1,15 +1,21 @@
|
||||
---
|
||||
name: vitest
|
||||
description: Vitest fast unit testing framework powered by Vite with Jest-compatible API. Use when writing tests, mocking, configuring coverage, or working with test filtering and fixtures.
|
||||
description: >-
|
||||
Vitest API and config reference (Jest-compatible) — mocking with vi.*, spies,
|
||||
fake timers, coverage configuration, fixtures, snapshots, and test filtering.
|
||||
Use for Vitest API and configuration questions anywhere in the monorepo; for
|
||||
Studio-specific test strategy and component-test setup, start with
|
||||
studio-testing and studio-mock-api-tests.
|
||||
metadata:
|
||||
author: Anthony Fu
|
||||
version: "2026.1.28"
|
||||
version: '2026.1.28'
|
||||
source: Generated from https://github.com/vitest-dev/vitest, scripts located at https://github.com/antfu/skills
|
||||
---
|
||||
|
||||
Vitest is a next-generation testing framework powered by Vite. It provides a Jest-compatible API with native ESM, TypeScript, and JSX support out of the box. Vitest shares the same config, transformers, resolvers, and plugins with your Vite app.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Vite-native: Uses Vite's transformation pipeline for fast HMR-like test updates
|
||||
- Jest-compatible: Drop-in replacement for most Jest test suites
|
||||
- Smart watch mode: Only reruns affected tests based on module graph
|
||||
@@ -22,31 +28,31 @@ Vitest is a next-generation testing framework powered by Vite. It provides a Jes
|
||||
|
||||
## Core
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) |
|
||||
| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) |
|
||||
| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) |
|
||||
| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) |
|
||||
| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) |
|
||||
| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) |
|
||||
| Topic | Description | Reference |
|
||||
| ------------- | --------------------------------------------------------------- | -------------------------------------------- |
|
||||
| Configuration | Vitest and Vite config integration, defineConfig usage | [core-config](references/core-config.md) |
|
||||
| CLI | Command line interface, commands and options | [core-cli](references/core-cli.md) |
|
||||
| Test API | test/it function, modifiers like skip, only, concurrent | [core-test-api](references/core-test-api.md) |
|
||||
| Describe API | describe/suite for grouping tests and nested suites | [core-describe](references/core-describe.md) |
|
||||
| Expect API | Assertions with toBe, toEqual, matchers and asymmetric matchers | [core-expect](references/core-expect.md) |
|
||||
| Hooks | beforeEach, afterEach, beforeAll, afterAll, aroundEach | [core-hooks](references/core-hooks.md) |
|
||||
|
||||
## Features
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) |
|
||||
| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) |
|
||||
| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) |
|
||||
| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) |
|
||||
| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) |
|
||||
| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) |
|
||||
| Topic | Description | Reference |
|
||||
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| Mocking | Mock functions, modules, timers, dates with vi utilities | [features-mocking](references/features-mocking.md) |
|
||||
| Snapshots | Snapshot testing with toMatchSnapshot and inline snapshots | [features-snapshots](references/features-snapshots.md) |
|
||||
| Coverage | Code coverage with V8 or Istanbul providers | [features-coverage](references/features-coverage.md) |
|
||||
| Test Context | Test fixtures, context.expect, test.extend for custom fixtures | [features-context](references/features-context.md) |
|
||||
| Concurrency | Concurrent tests, parallel execution, sharding | [features-concurrency](references/features-concurrency.md) |
|
||||
| Filtering | Filter tests by name, file patterns, tags | [features-filtering](references/features-filtering.md) |
|
||||
|
||||
## Advanced
|
||||
|
||||
| Topic | Description | Reference |
|
||||
|-------|-------------|-----------|
|
||||
| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) |
|
||||
| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) |
|
||||
| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) |
|
||||
| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) |
|
||||
| Topic | Description | Reference |
|
||||
| ------------ | ------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Vi Utilities | vi helper: mock, spyOn, fake timers, hoisted, waitFor | [advanced-vi](references/advanced-vi.md) |
|
||||
| Environments | Test environments: node, jsdom, happy-dom, custom | [advanced-environments](references/advanced-environments.md) |
|
||||
| Type Testing | Type-level testing with expectTypeOf and assertType | [advanced-type-testing](references/advanced-type-testing.md) |
|
||||
| Projects | Multi-project workspaces, different configs per project | [advanced-projects](references/advanced-projects.md) |
|
||||
+1
-1
@@ -24,7 +24,6 @@ pnpm 11 + Turborepo monorepo. Requires Node >= 22.13.
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
pnpm install # install dependencies
|
||||
pnpm dev:studio # run Studio dev server → http://localhost:8082
|
||||
pnpm dev:docs # run docs dev server
|
||||
pnpm dev:www # run www dev server
|
||||
@@ -63,6 +62,7 @@ The skills in `.claude/skills/` are the source of truth for conventions — load
|
||||
- `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts`
|
||||
- `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx`
|
||||
- `safe-sql-execution` — any code that builds or executes SQL against user databases
|
||||
- `react-hook-form` — writing or modifying any form code, anywhere in the monorepo
|
||||
- `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references
|
||||
|
||||
## Studio
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Edit(packages/api-types/types/**)",
|
||||
"Edit(**/routeTree.gen.ts)",
|
||||
"Edit(**/__generated__/**)",
|
||||
"Edit(apps/docs/features/docs/generated/**)",
|
||||
"Edit(apps/www/.generated/**)",
|
||||
"Edit(supabase/functions/common/database-types.ts)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: copywriting
|
||||
description: Write or audit UI copy (buttons, labels, empty states, error messages, tooltips, form text) anywhere in the monorepo. Always check this before shipping or reviewing user-facing text.
|
||||
description: Write or audit UI copy (buttons, labels, empty states, error messages, tooltips, form text) anywhere in the monorepo. Load it before shipping or reviewing any user-facing text — including when copy is incidental to the task, like a new feature that adds buttons, toasts, dialogs, or validation messages.
|
||||
---
|
||||
|
||||
# Copywriting
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: dev-toolbar-review
|
||||
description: Use when reviewing PRs that touch packages/dev-tools/, packages/common/posthog-client.ts,
|
||||
description: Safety rules for the dev toolbar, PostHog client, and feature flags. Use
|
||||
when writing or reviewing any change to packages/dev-tools/, packages/common/posthog-client.ts,
|
||||
or packages/common/feature-flags.tsx. Covers environment guards, flag override cookies,
|
||||
telemetry event subscription, and SSE stream safety.
|
||||
---
|
||||
@@ -30,10 +31,12 @@ so PRs touching only those files won't auto-request review. Watch for these in t
|
||||
**Files:** `packages/dev-tools/index.ts`, `DevToolbar.tsx`, `DevToolbarTrigger.tsx`, `DevToolbarContext.tsx`
|
||||
|
||||
The toolbar uses two layers of protection:
|
||||
|
||||
- **Build-time tree-shaking** in `index.ts`: `process.env.NODE_ENV !== 'development'` ternaries that replace components with noops/stubs so the implementation is eliminated from production bundles.
|
||||
- **Runtime guards** in components: `IS_LOCAL_DEV` checks — `DevToolbar` and `DevToolbarTrigger` return `null` to hide themselves, while `DevToolbarProvider` passes children through (`<>{children}</>`) to preserve the component tree.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Guards being removed or broadened. The toolbar is expanding to staging and preview deploys but must remain invisible in production.
|
||||
- Tree-shaking ternaries in `index.ts` staying intact — these are the primary production safety mechanism.
|
||||
- New components or exports that bypass the existing guard pattern.
|
||||
@@ -43,14 +46,17 @@ The toolbar uses two layers of protection:
|
||||
**Files:** `packages/dev-tools/DevToolbar.tsx`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx`
|
||||
|
||||
The toolbar writes two cookies that override feature flags locally:
|
||||
|
||||
- `x-ph-flag-overrides` — PostHog flag overrides
|
||||
- `x-cc-flag-overrides` — ConfigCat flag overrides
|
||||
|
||||
These are read by:
|
||||
|
||||
- `posthog-client.ts:getFeatureFlag()` — checks the PostHog override cookie before querying the SDK
|
||||
- `feature-flags.tsx` — merges both override cookies into the flag store during initialization
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Cookie name changes (must stay in sync across writer and all readers)
|
||||
- Changes to the merge/precedence logic in `feature-flags.tsx` (currently: `vercel-flag-overrides` first, then `x-cc-flag-overrides` takes precedence in local dev)
|
||||
- Override cookies being read outside the `IS_LOCAL_DEV` / `isLocalDev` guard — overrides must never affect production flag evaluation
|
||||
@@ -66,6 +72,7 @@ and `identify`. Note: `captureExperimentExposure` calls `posthog.capture()` dire
|
||||
without emitting to dev listeners — experiment exposure events are invisible in the toolbar.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Changes to `emitToDevListeners` or `subscribeToEvents` that could introduce side effects on the actual capture path (e.g., throwing errors, blocking, mutating event data)
|
||||
- The listener set (`devListeners`) being iterated synchronously in a way that could delay event dispatch
|
||||
- New PostHog client methods that capture events but don't call `emitToDevListeners` (gap in toolbar visibility)
|
||||
@@ -78,6 +85,7 @@ The toolbar connects to `${apiUrl}/telemetry/stream` via Server-Sent Events to d
|
||||
server-side telemetry. Uses exponential backoff on connection errors.
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Changes to the SSE endpoint URL or `session_id` cookie handling
|
||||
- Reconnection logic changes that could cause excessive retries or connection leaks
|
||||
- Note: the stream endpoint lives in the platform repo — cross-repo changes need coordinated review
|
||||
@@ -85,16 +93,19 @@ server-side telemetry. Uses exponential backoff on connection errors.
|
||||
### 5. App-Level Mounting
|
||||
|
||||
**Provider + toolbar panel** (`DevToolbarProvider`, `DevToolbar`):
|
||||
|
||||
- `apps/studio/pages/_app.tsx`
|
||||
- `apps/www/pages/_app.tsx`, `apps/www/app/providers.tsx`
|
||||
- `apps/docs/features/app.providers.tsx`
|
||||
|
||||
**Trigger button** (`DevToolbarTrigger`) — rendered separately in nav/header components:
|
||||
|
||||
- `apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx`
|
||||
- `apps/www/components/Nav/index.tsx`
|
||||
- `apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx`
|
||||
|
||||
**Check for:**
|
||||
|
||||
- Provider being added or removed from an app
|
||||
- `apiUrl` prop changes (must point to the correct platform API)
|
||||
- Rendering order changes that could affect the toolbar's access to PostHog context
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: react-hook-form
|
||||
description: Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions,
|
||||
reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE
|
||||
writing or modifying ANY form code, adding a field to an existing form, touching
|
||||
watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or
|
||||
sheet, or building a submit/cancel footer — even when the change looks trivial.
|
||||
The codebase contains widespread RHF anti-patterns; without this skill you will
|
||||
copy them. For form layout and which components to use, also load
|
||||
studio-ui-patterns.
|
||||
---
|
||||
|
||||
# React Hook Form
|
||||
|
||||
How to write forms that stay correct as they grow. The existing codebase is **not**
|
||||
a safe reference: `form.watch()` off prop-drilled form objects, subscription-only
|
||||
watches, unguarded `valueAsNumber`, and `?? undefined` controlled values are all
|
||||
common in older code and all wrong. Follow this skill, not the neighboring file.
|
||||
|
||||
**Policy — fix what you touch.** New code must follow these rules. When you modify
|
||||
existing form code, upgrade the specific fields/hooks/components you're editing to
|
||||
match (e.g. a component you touch that calls `form.watch` gets converted to
|
||||
`useWatch`). Leave untouched code alone, but tell the user about anti-patterns you
|
||||
noticed and didn't fix. Never add new violations: `react-hook-form/no-use-watch`
|
||||
is ratcheted in Studio CI — any increase in the warning count fails the build.
|
||||
|
||||
## Mental model: subscriptions decide who re-renders
|
||||
|
||||
RHF is uncontrolled at heart. Values live in refs; nothing re-renders unless a
|
||||
subscription says so. Every read API is a subscription decision:
|
||||
|
||||
| API | Subscribes | Re-renders | Use for |
|
||||
| ----------------------------- | ---------- | -------------------------- | ---------------------------------------------- |
|
||||
| `useWatch({ control, name })` | yes | only the calling component | reactive value reads, anywhere |
|
||||
| `useFormState({ control })` | yes | only the calling component | `isDirty`/`errors`/etc. outside the form owner |
|
||||
| `formState` (destructured) | yes | the `useForm` owner | form state **in the owner component only** |
|
||||
| `form.watch(name)` | yes | the **entire form tree** | avoid — lint-flagged, see below |
|
||||
| `getValues()` | no | never | event handlers and `onSubmit` only |
|
||||
| `subscribe()` | callback | none | side effects outside render |
|
||||
|
||||
Two facts explain most of the bugs we've shipped:
|
||||
|
||||
1. **`form.watch()` and `form.formState` hoist their subscription to the `useForm`
|
||||
owner**, no matter which component calls them. A child that reads
|
||||
`form.watch('x')` off a prop works today only because the whole tree re-renders
|
||||
on every change — it silently goes stale the moment anyone adds `React.memo`
|
||||
between owner and child, and until then it re-renders every sibling on every
|
||||
keystroke. A no-arg `form.watch()` sets `watchAll` and re-renders the tree on
|
||||
every field change for the life of the form.
|
||||
2. **`formState` is a Proxy** — reading a property is what arms the subscription.
|
||||
Destructure it (`const { isDirty } = form.formState`), never pass the object
|
||||
around or read it conditionally (`a && formState.isValid` may never subscribe).
|
||||
Enforced by `react-hook-form/destructuring-formstate` (error).
|
||||
|
||||
### Reading values, by location
|
||||
|
||||
- **In the component that owns `useForm`:** destructure `formState`; prefer
|
||||
`useWatch` over `form.watch` even here (the `no-use-watch` rule flags every
|
||||
`watch`, and `useWatch` scopes the re-render if the JSX is later extracted).
|
||||
- **In any child component or custom hook:** accept `control` (not the whole
|
||||
`form`) and use `useWatch({ control, name })` / `useFormState({ control })`.
|
||||
Inside `<Form {...form}>` (which _is_ `FormProvider`), `useFormContext()` +
|
||||
`useWatch({ name })` also works and avoids prop-drilling entirely.
|
||||
- **Consume the return value.** Never call a watch for its subscription side
|
||||
effect and then read via `getValues()` — the watch list and the read list will
|
||||
drift apart (it has already happened; fields silently lost reactivity). The
|
||||
value you render must _be_ the value you subscribed to.
|
||||
- **One read path per value per render.** Mixing `useWatch('x')` on one line and
|
||||
`getValues('x')` a few lines later lets the two disagree within a single render.
|
||||
- **Name what you watch.** `useWatch({ control })` with no `name` re-renders on
|
||||
every keystroke in every field. Subscribe to the specific names you use.
|
||||
- `watch(callback)` is deprecated — use `subscribe()` for render-free listeners,
|
||||
and always return its cleanup from `useEffect`.
|
||||
|
||||
```tsx
|
||||
// ❌ common in the codebase — all three subscriptions hoist to the form owner
|
||||
function Fields({ form }: { form: UseFormReturn<FormValues> }) {
|
||||
form.watch(['storageType', 'totalSize']) // return value discarded
|
||||
const { errors } = form.formState // prop-form formState
|
||||
const size = form.getValues('totalSize') // non-reactive read in render
|
||||
...
|
||||
}
|
||||
|
||||
// ✅ child subscribes for itself and consumes what it watches
|
||||
function Fields({ control }: { control: Control<FormValues> }) {
|
||||
const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] })
|
||||
const { errors } = useFormState({ control })
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## The canonical form
|
||||
|
||||
zod schema → `z.infer` type → `useForm` with `zodResolver` and **complete**
|
||||
`defaultValues` → `<Form {...form}>` → `FormField` render-prop per field →
|
||||
`FormItemLayout` → `FormControl` → primitive from `ui`. Layout/container choices
|
||||
(Card vs Sheet, `layout=` variants) are covered by the `studio-ui-patterns` skill
|
||||
and the demos in `apps/design-system/registry/default/example/`
|
||||
(`form-patterns-pagelayout.tsx`, `form-patterns-sidepanel.tsx`) — check them
|
||||
before inventing structure.
|
||||
|
||||
```tsx
|
||||
// Module level — static references, not recreated on every render
|
||||
const FORM_ID = 'pool-config-form'
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
maxConnections: z
|
||||
.union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')])
|
||||
.refine((v) => v !== '', 'Max connections is required'),
|
||||
})
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
const defaultValues: FormValues = { name: '', maxConnections: '' }
|
||||
|
||||
// Inside the component
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout layout="horizontal" label="Name">
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
```
|
||||
|
||||
Define the schema, `type`, static `defaultValues`, and the form's id at module
|
||||
level, outside the component. Rebuilding them per render is wasted work and
|
||||
unstable references — RHF reads `defaultValues` only on the first render, but
|
||||
anything else comparing against these objects sees a fresh identity each time.
|
||||
When they genuinely depend on runtime data, build the schema with `useMemo` and
|
||||
feed server-driven defaults through the `values` option (next section) instead
|
||||
of hoisting.
|
||||
|
||||
Submit buttons living outside the `<form>` (sheet/dialog footers) use the same
|
||||
module-level `FORM_ID` via `form={FORM_ID}` on the button. A module-level id is
|
||||
only safe for singleton forms — if the component can mount more than once at a
|
||||
time, duplicate ids make external buttons submit the first matching form, so
|
||||
mint a per-instance id with `useId()` and share it between the `<form>` and its
|
||||
buttons.
|
||||
|
||||
## defaultValues, server data, and reset
|
||||
|
||||
- **Provide a complete `defaultValues` object — every field, no `undefined`.**
|
||||
`isDirty`, `dirtyFields`, and Cancel-reset all compare against it; a missing or
|
||||
`undefined` default breaks all three, and `undefined` also makes React treat the
|
||||
input as uncontrolled (see below).
|
||||
- **Form populated from an API? Use the `values` option, not a hand-rolled
|
||||
effect.** `values` reacts to the query resolving and resets the form for you;
|
||||
computing `defaultValues` from a query that may not have loaded freezes whatever
|
||||
happened to be in cache at mount. Add
|
||||
`resetOptions: { keepDirtyValues: true }` when a background refetch must not
|
||||
clobber the user's in-progress edits. (Good examples:
|
||||
`components/interfaces/Settings/Database/ConnectionLogging.tsx`,
|
||||
`components/interfaces/Storage/EditBucketModal.tsx`.)
|
||||
- **After a successful mutation, re-baseline the form** in `onSuccess` so the
|
||||
saved state becomes the new baseline (`isDirty` returns to false, Cancel now
|
||||
reverts to the saved values). Prefer what the server actually persisted: if the
|
||||
form uses `values` and the mutation invalidates the query, the refetch handles
|
||||
this for you; if the mutation returns the updated resource, `reset(response)`.
|
||||
`reset(submittedValues)` is the fallback for APIs that store exactly what was
|
||||
sent — if the server normalizes or fills values, it baselines the form to data
|
||||
that was never saved. A bare `reset()` reverts to the _previous_ defaults —
|
||||
wrong after a save.
|
||||
- Cancel buttons call `form.reset()`. This only visually restores fields whose
|
||||
values round-trip through defined, controlled values — which is why the null
|
||||
rules below matter.
|
||||
|
||||
## Controlled inputs: never let `value` flip to `undefined`
|
||||
|
||||
React decides controlled vs uncontrolled per render from whether `value` is
|
||||
defined. A field whose value can be `undefined` (or becomes `undefined` on reset)
|
||||
flips modes: console warnings, and — worse — `reset()` stops clearing the visible
|
||||
text because React abandoned the DOM value. `value={field.value ?? undefined}` is
|
||||
a bug, not a fix.
|
||||
|
||||
- Text fields: default to `''`, never `null`/`undefined`.
|
||||
- **Normalize `null` from the API at the form boundary** (`growthPercent ?? ''`
|
||||
when building defaults) and convert back on submit (`'' → null`). Do not paper
|
||||
over a `null` default with a `placeholder` that looks like a value: the user
|
||||
sees "50", the form holds `null`, and every downstream comparison
|
||||
(`defaultValues.growthPercent !== watched` → `null !== 50`) reports a permanent
|
||||
phantom change while Cancel silently fails to reset the field.
|
||||
- Selects/radios: default to `''` or a real option value; checkboxes/switches to
|
||||
`false`.
|
||||
|
||||
## Number inputs
|
||||
|
||||
The blessed pattern keeps `''` as the "empty" sentinel so the input stays
|
||||
controlled, and lets zod coerce on validation (see `maxConnections` above):
|
||||
`z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…')`
|
||||
with a plain `<Input {...field} type="number" />`.
|
||||
|
||||
If you instead wire `onChange` through `e.target.valueAsNumber` (or
|
||||
`valueAsNumber: true`), an empty or partially-typed input produces `NaN`, which
|
||||
lands in form state and propagates into every calculation, price preview, and
|
||||
`value` attribute downstream. Guard it with the **same empty sentinel the
|
||||
field's schema declares** — with the `''`-union schema above:
|
||||
`field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)`.
|
||||
Never let `NaN` into form state.
|
||||
|
||||
A nullable API field (`null` = "unset", e.g. a platform default applies)
|
||||
doesn't change the in-form sentinel — keep `''` inside the form and convert at
|
||||
the boundaries:
|
||||
|
||||
```tsx
|
||||
// inbound: null → '' when building defaults/values
|
||||
values: { growthPercent: data.growth_percent ?? '' },
|
||||
// schema: '' stays the in-form sentinel, zod coerces real input
|
||||
growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]),
|
||||
// outbound: '' → null in onSubmit
|
||||
mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent })
|
||||
```
|
||||
|
||||
If `null` does end up in form state (some existing forms hold it), keep it out
|
||||
of both the input and the coercion: render via `value={field.value ?? ''}`, and
|
||||
don't pass the value through `z.coerce.number()` — `Number(null)` is `0`, so a
|
||||
nullable field fed into the coercing union silently validates empty as `0`.
|
||||
Either way it's one sentinel per field, used consistently across defaults,
|
||||
schema, `onChange`, rendering, and the submit mapping.
|
||||
|
||||
## Dirty state and change detection
|
||||
|
||||
- Gate Save on `isDirty`; show Cancel only when dirty. In the owner, destructure
|
||||
from `form.formState`; anywhere else, `useFormState({ control })`.
|
||||
- To show _which_ fields changed (review/summary dialogs), read `dirtyFields`
|
||||
from the same subscription instead of hand-comparing
|
||||
`defaultValues.x !== watchedX`. RHF already does that comparison correctly;
|
||||
hand-rolled versions break on the null-vs-placeholder mismatch and must be
|
||||
kept in sync with the watch list by hand.
|
||||
- `setValue` outside user input needs explicit flags:
|
||||
`setValue('x', v, { shouldDirty: true, shouldValidate: true })` — otherwise the
|
||||
change is invisible to `isDirty` and validation.
|
||||
|
||||
## Disabling and gating
|
||||
|
||||
If a field must not be edited (plan tier, permissions, cooldown), disable the
|
||||
field itself — a notice next to an editable input gates nothing. Wire the same
|
||||
condition into both the notice and the control. Permission checks come from
|
||||
`useAsyncCheckPermissions`; disabled buttons that need an explanation use
|
||||
`ButtonTooltip`.
|
||||
|
||||
Caution: `register`/`useController` `disabled: true` removes the field's value
|
||||
from submission data. For "visible but locked" fields whose value must survive
|
||||
submit, use the input's own `disabled`/`readOnly` prop (as `FormField` +
|
||||
primitive props do) rather than RHF-level disabling, or the form-level
|
||||
`disabled` option to freeze everything during async work.
|
||||
|
||||
## Submit and mutations
|
||||
|
||||
`onSubmit` receives validated, typed data — trust it; don't re-read via
|
||||
`getValues()`. Mutations follow Studio conventions: `onSuccess` → `toast.success`
|
||||
|
||||
- `reset(values)` (or query invalidation when using `values:`), `onError` →
|
||||
`toast.error`; pass the mutation's `isPending` to the button's `loading` prop.
|
||||
Default validation `mode: 'onSubmit'` is right for most forms — pick another mode
|
||||
deliberately, not by copying.
|
||||
|
||||
## Lint rules in force (Studio)
|
||||
|
||||
| Rule | Level | Meaning |
|
||||
| ------------------------------------------- | ---------------- | ------------------------------------------------ |
|
||||
| `react-hook-form/destructuring-formstate` | error | destructure `formState`, never hold the object |
|
||||
| `react-hook-form/no-access-control` | error | don't reach into `control` internals |
|
||||
| `react-hook-form/no-nested-object-setvalue` | error | `setValue('a.b', v)`, not `setValue('a', {b:v})` |
|
||||
| `react-hook-form/no-use-watch` | warn (ratcheted) | use `useWatch`, not `watch` |
|
||||
@@ -1,6 +1,20 @@
|
||||
---
|
||||
name: safe-sql-execution
|
||||
description: Safely execute SQL queries against a user database without risking SQL injection or other security vulnerabilities.
|
||||
description: >-
|
||||
Use whenever code will build, return, fetch, or execute SQL that runs against
|
||||
a user's real Postgres database — even when the request reads like an ordinary
|
||||
feature or bug fix and never says "security," "injection," or
|
||||
"SafeSqlFragment." This covers: writing or editing any pg-meta function, query
|
||||
builder, or endpoint that builds/returns SQL for database objects (tables,
|
||||
views, functions, DB triggers, indexes, RLS policies); interpolating a
|
||||
schema/table/column/search/route-param value into SQL text; storing, fetching,
|
||||
or re-running SQL that round-trips from the database (a policy's definition, a
|
||||
function/view definition, a snippet's saved content); and any
|
||||
"Run"/"Apply"/"Execute" action that sends SQL to a project's database (SQL
|
||||
editor run-selection, policy editor apply, snippet runner). Load this BEFORE
|
||||
writing such code, not only when reviewing a finished diff. Skip only for
|
||||
changes that never touch SQL text or execution — styling, unrelated data
|
||||
hooks, non-SQL form validation, or UI layout work.
|
||||
---
|
||||
|
||||
# Safe SQL execution
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
name: studio-best-practices
|
||||
description: React and TypeScript best practices for Supabase Studio. Use when writing
|
||||
or reviewing Studio components — covers boolean naming, component structure, loading/error
|
||||
states, state management, custom hooks, event handlers, conditional rendering,
|
||||
performance, and TypeScript conventions.
|
||||
---
|
||||
|
||||
# Studio Best Practices
|
||||
|
||||
Applies to `apps/studio/**/*.{ts,tsx}`.
|
||||
|
||||
## Boolean Naming
|
||||
|
||||
Use descriptive prefixes — derive from existing state rather than storing separately:
|
||||
|
||||
- `is` — state/identity: `isLoading`, `isPaused`, `isNewRecord`
|
||||
- `has` — possession: `hasPermission`, `hasData`
|
||||
- `can` — capability: `canUpdateColumns`, `canDelete`
|
||||
- `should` — conditional behavior: `shouldFetch`, `shouldRender`
|
||||
|
||||
Extract complex conditions into named variables:
|
||||
|
||||
```tsx
|
||||
// ❌ inline multi-condition
|
||||
{
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading && <Button />
|
||||
}
|
||||
|
||||
// ✅ named variable
|
||||
const canShowAddButton =
|
||||
!isSchemaLocked && isTableLike(selectedTable) && canUpdateColumns && !isLoading
|
||||
{
|
||||
canShowAddButton && <Button />
|
||||
}
|
||||
```
|
||||
|
||||
Derive booleans — don't store them:
|
||||
|
||||
```tsx
|
||||
// ❌ stored derived state
|
||||
const [isFormValid, setIsFormValid] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsFormValid(name.length > 0 && email.includes('@'))
|
||||
}, [name, email])
|
||||
|
||||
// ✅ derived
|
||||
const isFormValid = name.length > 0 && email.includes('@')
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
See `vercel-composition-patterns` skill for compound component and composition patterns.
|
||||
|
||||
Keep components under 200–300 lines. Split when you see:
|
||||
|
||||
- Multiple distinct UI sections
|
||||
- Complex conditional rendering
|
||||
- Multiple unrelated `useState` calls
|
||||
- Hard to understand at a glance
|
||||
|
||||
Co-locate sub-components in the same directory as the parent. Avoid barrel re-export files.
|
||||
|
||||
Extract repeated JSX patterns into small components.
|
||||
|
||||
## Data Fetching
|
||||
|
||||
All data fetching uses TanStack Query (React Query). See `studio-queries` skill for query/mutation patterns and `studio-error-handling` skill for error display conventions.
|
||||
|
||||
### Loading / Error / Success Pattern
|
||||
|
||||
Top level:
|
||||
|
||||
```tsx
|
||||
const { data, error, isLoading, isError, isSuccess } = useQuery(...)
|
||||
|
||||
if (isLoading) return <GenericSkeletonLoader />
|
||||
if (isError) return <AlertError error={error} subject="Failed to load data" />
|
||||
if (isSuccess && data.length === 0) return <EmptyState />
|
||||
return <DataDisplay data={data} />
|
||||
```
|
||||
|
||||
Use early returns — avoid deeply nested conditionals.
|
||||
|
||||
Inline:
|
||||
|
||||
```tsx
|
||||
<div>
|
||||
{isLoading && <InlineLoader />}
|
||||
{isError && <InlineError error={error} />}
|
||||
{isSuccess && data.length === 0 && <EmptyState />}
|
||||
{isSuccess && data.length > 0 && <DataDisplay data={data} />}
|
||||
</div>
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
Keep state as local as possible; lift only when needed.
|
||||
|
||||
Group related form state with `react-hook-form` rather than multiple `useState` calls. See `studio-ui-patterns` skill for form layout and component conventions.
|
||||
|
||||
```tsx
|
||||
// ❌ multiple related useState
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
// ✅ grouped with react-hook-form
|
||||
const form = useForm<FormValues>({ defaultValues: { name: '', email: '' } })
|
||||
```
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
Extract complex or reusable logic into hooks. Return objects, not arrays:
|
||||
|
||||
```tsx
|
||||
// ❌ array return (hard to extend)
|
||||
return [value, toggle]
|
||||
|
||||
// ✅ object return
|
||||
return { value, toggle, setTrue, setFalse }
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
- Prop callbacks: `on` prefix (`onClose`, `onSave`)
|
||||
- Internal handlers: `handle` prefix (`handleSubmit`, `handleCancel`)
|
||||
|
||||
Use `useCallback` for handlers passed to memoized children; avoid unnecessary inline arrow functions.
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```tsx
|
||||
// Simple show/hide
|
||||
<>{isVisible && <Component />}</>
|
||||
|
||||
// Binary choice
|
||||
<>{isLoading ? <Spinner /> : <Content />}</>
|
||||
|
||||
// Multiple conditions — use early returns, not nested ternaries
|
||||
if (isLoading) return <Spinner />
|
||||
if (isError) return <Error />
|
||||
return <Content />
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
`useMemo` for genuinely expensive computations (measured, not assumed). Don't wrap everything — only optimize when you have a measured problem or are passing values to memoized children.
|
||||
|
||||
## TypeScript
|
||||
|
||||
Define prop interfaces explicitly. Use discriminated unions for complex state:
|
||||
|
||||
```tsx
|
||||
type AsyncState<T> =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: T }
|
||||
| { status: 'error'; error: Error }
|
||||
```
|
||||
|
||||
Avoid `as any` / `as Type` casts. Validate at boundaries with zod:
|
||||
|
||||
```tsx
|
||||
// ❌ type cast
|
||||
const user = apiResponse as User
|
||||
|
||||
// ✅ zod parse
|
||||
const user = userSchema.parse(apiResponse)
|
||||
// or safe:
|
||||
const result = userSchema.safeParse(apiResponse)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Extract logic into `.utils.ts` pure functions and test exhaustively. See the `studio-testing` skill for the full testing strategy and decision tree.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: studio-e2e-tests
|
||||
description: Write and run Playwright E2E tests for Supabase Studio. Use when asked
|
||||
to run e2e tests, write new E2E tests, or debug flaky tests. Covers running commands,
|
||||
avoiding race conditions, waiting strategies, selectors, helper functions, and CI
|
||||
vs local differences.
|
||||
description: Write and run Playwright E2E tests for Supabase Studio (e2e/studio).
|
||||
Use when asked to run e2e tests, write new E2E tests, or debug flaky or failing
|
||||
Playwright tests. Covers running commands, avoiding race conditions, waiting
|
||||
strategies, selectors, helper functions, and CI vs local differences.
|
||||
---
|
||||
|
||||
# E2E Studio Tests
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
name: studio-error-handling
|
||||
description: Error display and troubleshooting pattern for Supabase Studio. Use when
|
||||
rendering API errors in the UI, adding inline troubleshooting steps for a new
|
||||
error type, or wiring up the AI assistant debug button from an error state.
|
||||
showing a failed API request or query error in the UI (AlertError, toast, inline
|
||||
message), adding troubleshooting steps for a new error type, or wiring up the AI
|
||||
assistant debug button from an error state.
|
||||
---
|
||||
|
||||
# Studio Error Handling Pattern
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
name: studio-queries
|
||||
description: React Query conventions for data fetching in Supabase Studio. Use when
|
||||
writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/.
|
||||
writing or reviewing query hooks, mutation hooks, or query keys in apps/studio/data/
|
||||
— including adding the first fetch or mutation for a new API endpoint or resource.
|
||||
Covers queryOptions pattern, keys.ts structure, mutation hook template, and imperative
|
||||
fetching.
|
||||
---
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: studio-testing
|
||||
description: Testing strategy for Supabase Studio. Use when writing tests, deciding what
|
||||
type of test to write, extracting logic from components into testable utility
|
||||
functions, or reviewing test coverage. Covers unit tests, component tests,
|
||||
and E2E test selection criteria.
|
||||
description: Testing strategy for Supabase Studio. Use when writing tests, deciding
|
||||
whether a change needs tests and which type, extracting logic from components into
|
||||
testable utility functions, or reviewing test coverage. Covers unit tests, component
|
||||
tests, and E2E test selection criteria.
|
||||
---
|
||||
|
||||
# Studio Testing Strategy
|
||||
@@ -162,14 +162,14 @@ try/finally for resource cleanup. For E2E execution details, see the
|
||||
|
||||
## Codebase References
|
||||
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| What | Where |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Util test examples | `apps/studio/tests/components/Grid/Grid.utils.test.ts`, `apps/studio/tests/components/Billing/TaxID.utils.test.ts`, `apps/studio/tests/components/Editor/SpreadsheetImport.utils.test.ts` |
|
||||
| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `apps/studio/tests/README.md` |
|
||||
| Vitest config | `apps/studio/vitest.config.ts` |
|
||||
| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
| Component test examples | `apps/studio/tests/features/logs/LogsFilterPopover.test.tsx`, `apps/studio/tests/components/CopyButton.test.tsx` |
|
||||
| E2E test example | `e2e/studio/features/filter-bar.spec.ts` |
|
||||
| E2E helpers pattern | `e2e/studio/utils/filter-bar-helpers.ts` |
|
||||
| Custom render | `apps/studio/tests/lib/custom-render.tsx` |
|
||||
| MSW mock setup | `apps/studio/tests/lib/msw.ts` (`addAPIMock`) |
|
||||
| Test README | `apps/studio/tests/README.md` |
|
||||
| Vitest config | `apps/studio/vitest.config.ts` |
|
||||
| Related skills | `studio-e2e-tests` (running E2E), `vitest` (API reference), `vercel-composition-patterns` (component architecture) |
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
name: telemetry-standards
|
||||
description: PostHog event tracking standards for Supabase Studio. Use when reviewing
|
||||
PRs for telemetry compliance or implementing new event tracking. Covers event naming,
|
||||
property conventions, approved patterns, and implementation guide.
|
||||
description: PostHog event tracking standards for Supabase Studio. Use when adding
|
||||
useTrack() calls, defining events in packages/common/telemetry-constants.ts,
|
||||
implementing tracking for a new feature, or reviewing PRs for telemetry compliance.
|
||||
Covers event naming, property conventions, approved patterns, and implementation
|
||||
guide.
|
||||
---
|
||||
|
||||
# Telemetry Standards for Supabase Studio
|
||||
@@ -19,16 +21,19 @@ opened, clicked, submitted, created, removed, updated, intended, evaluated, adde
|
||||
enabled, disabled, copied, exposed, failed, converted, closed, completed, applied, sent, moved
|
||||
|
||||
**Flag these:**
|
||||
|
||||
- Unapproved verbs (saved, viewed, seen, pressed, etc.)
|
||||
- Wrong order: `click_product_card` → should be `product_card_clicked`
|
||||
- Wrong casing: `productCardClicked` → should be `product_card_clicked`
|
||||
|
||||
**Good examples:**
|
||||
|
||||
- `product_card_clicked`
|
||||
- `backup_button_clicked`
|
||||
- `sql_query_submitted`
|
||||
|
||||
**Common mistakes with corrections:**
|
||||
|
||||
- `database_saved` → `save_button_clicked` or `database_updated` (unapproved verb)
|
||||
- `click_backup_button` → `backup_button_clicked` (wrong order)
|
||||
- `dashboardViewed` → don't track passive views on page load
|
||||
@@ -39,10 +44,12 @@ enabled, disabled, copied, exposed, failed, converted, closed, completed, applie
|
||||
**Casing:** camelCase preferred for new events. The codebase has existing snake_case properties (e.g., `schema_name`, `table_name`) — when adding properties to an existing event, match its established convention.
|
||||
|
||||
**Names must be self-explanatory:**
|
||||
|
||||
- `{ productType: 'database', planTier: 'pro' }`
|
||||
- `{ assistantType: 'sql', suggestionType: 'optimization' }`
|
||||
|
||||
**Flag these:**
|
||||
|
||||
- Generic names: `label`, `value`, `name`, `data`
|
||||
- PascalCase properties
|
||||
- Inconsistent names across similar events (e.g., `assistantType` in one event, `aiType` in a related event)
|
||||
@@ -117,6 +124,7 @@ When reviewing a PR, flag these as **required changes:**
|
||||
5. **Inaccurate docs** — `@page`/`@source` descriptions that don't match the actual implementation
|
||||
|
||||
When a PR adds user-facing interactions (buttons, forms, toggles, modals) **without** tracking, suggest:
|
||||
|
||||
- "This adds a user interaction that may benefit from tracking."
|
||||
- Propose the event name following `[object]_[verb]` convention
|
||||
- Propose the `useTrack()` call with suggested properties
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ knowledge_base:
|
||||
code_guidelines:
|
||||
filePatterns:
|
||||
# Studio code conventions — React/TS, UI patterns, composition, data fetching, errors
|
||||
- files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling}/SKILL.md'
|
||||
- files: '.claude/skills/{studio-best-practices,studio-ui-patterns,vercel-composition-patterns,studio-queries,studio-error-handling,react-hook-form}/SKILL.md'
|
||||
applyTo: 'apps/studio/**/*.{ts,tsx}'
|
||||
# Studio unit / component test conventions
|
||||
- files: '.claude/skills/{studio-testing,studio-mock-api-tests}/SKILL.md'
|
||||
|
||||
@@ -5,10 +5,15 @@ on:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
||||
branches: ['master']
|
||||
paths:
|
||||
- 'apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx'
|
||||
- 'apps/docs/content/_partials/quickstart_db_setup.mdx'
|
||||
- 'apps/docs/content/_partials/api_settings.mdx'
|
||||
- 'e2e/docs/**'
|
||||
- 'apps/docs/content/guides/**/*.mdx'
|
||||
- 'apps/docs/content/troubleshooting/**/*.mdx'
|
||||
- 'apps/docs/content/_partials/**'
|
||||
- 'e2e/docs/features/**'
|
||||
- 'e2e/docs/utils/**'
|
||||
- 'e2e/docs/scripts/**'
|
||||
- 'e2e/docs/playwright.config.ts'
|
||||
- 'e2e/docs/package.json'
|
||||
- 'e2e/docs/tsconfig.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/docs-e2e.yml'
|
||||
workflow_dispatch:
|
||||
@@ -18,6 +23,11 @@ on:
|
||||
required: false
|
||||
default: 'https://supabase.com'
|
||||
type: string
|
||||
page_paths:
|
||||
description: 'Comma-separated /docs/... paths to test (required for manual runs)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -25,7 +35,6 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: read
|
||||
statuses: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -43,14 +52,68 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Need full history on PRs so we can diff against the base branch.
|
||||
# Use string '0' — numeric 0 is falsy in GitHub Actions expressions.
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
|
||||
sparse-checkout: |
|
||||
e2e/docs
|
||||
scripts
|
||||
patches
|
||||
apps/docs/content/guides
|
||||
apps/docs/content/troubleshooting
|
||||
apps/docs/content/_partials
|
||||
apps/docs/scripts/federated-content/sources
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
# Map changed owned content (guides, troubleshooting, partials) to page
|
||||
# URLs. Harness-only PRs resolve to skip=true and exit before Playwright.
|
||||
- name: Resolve docs E2E scope
|
||||
id: scope
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PAGE_PATHS_INPUT: ${{ inputs.page_paths }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
if [ -z "$PAGE_PATHS_INPUT" ]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "paths=" >> "$GITHUB_OUTPUT"
|
||||
echo "Manual run requires the page_paths input."
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
printf 'paths=%s\n' "$PAGE_PATHS_INPUT" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git diff --name-only --diff-filter=ACMR "origin/$BASE_REF"...HEAD \
|
||||
| node --experimental-strip-types e2e/docs/scripts/resolve-docs-scope.ts
|
||||
|
||||
- name: Skip Playwright (no in-scope pages)
|
||||
if: steps.scope.outputs.skip == 'true'
|
||||
run: echo "No in-scope docs pages changed; skipping Playwright suite."
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Enable pnpm store cache
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Vercel skips the docs preview when a PR only changes the harness
|
||||
# (e2e/docs, workflow). Wait for a preview only when apps/docs changed.
|
||||
- name: Detect docs app changes
|
||||
if: github.event_name == 'pull_request'
|
||||
if: steps.scope.outputs.skip != 'true' && github.event_name == 'pull_request'
|
||||
id: filter
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
@@ -58,16 +121,24 @@ jobs:
|
||||
docs_app:
|
||||
- 'apps/docs/**'
|
||||
|
||||
# Vercel's GitHub App stopped writing GitHub Deployment objects on
|
||||
# 2026-02-17 (broken app auth), so vercel/wait-for-deployment-action
|
||||
# times out polling that API even though the preview builds fine.
|
||||
# Poll the "Vercel – docs" commit status instead — Vercel keeps posting
|
||||
# those — then resolve the deployment it points to via Vercel's own API
|
||||
# to get the actual preview URL. See scripts/waitForVercelDocsPreview.js.
|
||||
- name: Wait for Vercel docs preview
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true'
|
||||
if: steps.scope.outputs.skip != 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && steps.filter.outputs.docs_app == 'true'
|
||||
id: deployment
|
||||
uses: vercel/wait-for-deployment-action@0e2b0c5c5cce31f1648108aeec56467187aca037
|
||||
with:
|
||||
project-slug: docs
|
||||
environment: preview
|
||||
timeout: '900'
|
||||
run: node scripts/waitForVercelDocsPreview.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
||||
|
||||
- name: Resolve base URL
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
id: base-url
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -87,32 +158,25 @@ jobs:
|
||||
echo "use_bypass=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
run: pnpm install --frozen-lockfile --filter=e2e-docs...
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell
|
||||
|
||||
- name: Run docs E2E
|
||||
if: steps.scope.outputs.skip != 'true'
|
||||
working-directory: e2e/docs
|
||||
run: pnpm run e2e:docs
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: ${{ steps.base-url.outputs.url }}
|
||||
DOCS_E2E_PAGE_PATHS: ${{ steps.scope.outputs.paths }}
|
||||
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ steps.base-url.outputs.use_bypass == 'true' && secrets.VERCEL_AUTOMATION_BYPASS_DOCS || '' }}
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
if: failure() && steps.scope.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: docs-playwright-report
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Update Mgmt Api Docs
|
||||
on:
|
||||
schedule:
|
||||
# Run at 00:00 UTC every Monday
|
||||
- cron: '0 0 * * 1'
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
ref: ${{ github.ref }}
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
patches
|
||||
@@ -32,8 +32,8 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -55,8 +55,8 @@ jobs:
|
||||
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
commit-message: 'feat: update mgmt api docs'
|
||||
title: 'feat: update mgmt api docs'
|
||||
body: 'This PR updates mgmt api docs automatically.'
|
||||
branch: 'gha/auto-update-mgmt-api-docs'
|
||||
base: 'master'
|
||||
commit-message: "feat: update mgmt api docs"
|
||||
title: "feat: update mgmt api docs"
|
||||
body: "This PR updates mgmt api docs automatically."
|
||||
branch: "gha/auto-update-mgmt-api-docs"
|
||||
base: "master"
|
||||
@@ -6,6 +6,11 @@ on:
|
||||
paths:
|
||||
- 'apps/docs/**/*.ts*'
|
||||
- 'apps/docs/spec/**/*.json'
|
||||
- 'apps/docs/.env.development'
|
||||
- 'apps/docs/package.json'
|
||||
- 'e2e/docs/local-smoke/**'
|
||||
- 'e2e/docs/playwright.local-smoke.config.ts'
|
||||
- 'e2e/docs/package.json'
|
||||
|
||||
# Cancel old builds on new commit for same workflow + branch/PR
|
||||
concurrency:
|
||||
@@ -70,3 +75,52 @@ jobs:
|
||||
echo "GITHUB_CLIENT_ID=dummy-id" >> .env
|
||||
echo "GITHUB_SECRET=dummy-secret" >> .env
|
||||
pnpm run test:docs
|
||||
|
||||
local-dev-smoke:
|
||||
name: Local dev smoke (no credentials)
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
apps/docs
|
||||
examples
|
||||
packages
|
||||
supabase
|
||||
patches
|
||||
e2e/docs
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm -C e2e/docs exec playwright install chromium --with-deps --only-shell
|
||||
|
||||
# Deliberately does not set DOCS_GITHUB_APP_*, SUPABASE_SECRET_KEY,
|
||||
# OPENAI_API_KEY, or DOCS_REVALIDATION_KEYS — their absence here is what
|
||||
# verifies `pnpm run dev:docs` still works without private credentials.
|
||||
- name: Run local dev smoke tests
|
||||
run: pnpm run e2e:docs:local-smoke
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: docs-local-smoke-playwright-report
|
||||
path: |
|
||||
e2e/docs/playwright-report-local-smoke/
|
||||
e2e/docs/test-results/
|
||||
retention-days: 7
|
||||
@@ -36,3 +36,8 @@ jobs:
|
||||
- name: Build
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --no-cache
|
||||
# Reuses the shared stages (deps/dev) from the first build's layer
|
||||
# cache; only the tanstack build/runtime stages run fresh.
|
||||
- name: Build (tanstack)
|
||||
if: steps.filter.outputs.studio == 'true'
|
||||
run: docker build . -f apps/studio/Dockerfile --target production -t supabase-studio:local-tanstack --build-arg NEXT_PUBLIC_STUDIO_AUTH_MODE=supabase --build-arg STUDIO_FRAMEWORK=tanstack
|
||||
@@ -37,6 +37,12 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
# Needs no dependencies, so it runs before install and fails fast. Catches a
|
||||
# class of bug that only breaks on case-insensitive filesystems (macOS and
|
||||
# Windows dev machines) and is therefore invisible to typecheck/lint on CI.
|
||||
- name: Check for case-sensitivity hazards
|
||||
run: node scripts/check-case-hazards.mjs
|
||||
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ You can run any of the sites individually by using the scope name. For example:
|
||||
pnpm dev:www
|
||||
```
|
||||
|
||||
Note: Particularly for `www` make sure you have copied `apps/www/.env.local.example` to `apps/www/.env.local`
|
||||
Note: Particularly for `www` make sure you have copied `apps/www/.env.local.example` to `apps/www/.env.local`. For `docs`, see [`apps/docs/DEVELOPERS.md`](./apps/docs/DEVELOPERS.md).
|
||||
|
||||
#### Shared components
|
||||
|
||||
|
||||
@@ -146,7 +146,8 @@ export default function Component() {
|
||||
{['desktop', 'mobile'].map((key) => {
|
||||
const chart = key as keyof typeof chartConfig
|
||||
return (
|
||||
<button tabIndex={0}
|
||||
<button
|
||||
tabIndex={0}
|
||||
key={chart}
|
||||
data-active={activeChart === chart}
|
||||
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-surface-100 sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
|
||||
|
||||
@@ -2502,6 +2502,17 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"skip-to-content-demo": {
|
||||
name: "skip-to-content-demo",
|
||||
type: "components:example",
|
||||
registryDependencies: undefined,
|
||||
component: React.lazy(() => import("@/registry/default/example/skip-to-content-demo")),
|
||||
source: "",
|
||||
files: ["registry/default/example/skip-to-content-demo.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"page-container-demo": {
|
||||
name: "page-container-demo",
|
||||
type: "components:example",
|
||||
@@ -2612,6 +2623,17 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"connect-interstitial-action-error": {
|
||||
name: "connect-interstitial-action-error",
|
||||
type: "components:example",
|
||||
registryDependencies: undefined,
|
||||
component: React.lazy(() => import("@/registry/default/example/connect-interstitial-action-error")),
|
||||
source: "",
|
||||
files: ["registry/default/example/connect-interstitial-action-error.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"connect-interstitial-logo-pair": {
|
||||
name: "connect-interstitial-logo-pair",
|
||||
type: "components:example",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ScrollArea } from 'ui'
|
||||
import { SkipToContent } from 'ui-patterns/SkipToContent'
|
||||
|
||||
import { MobileSidebarSheet } from '@/components/mobile-sidebar-sheet'
|
||||
import { SideNavigation } from '@/components/side-navigation'
|
||||
@@ -12,18 +13,22 @@ interface AppLayoutProps {
|
||||
export default async function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<SkipToContent href="#main" />
|
||||
<TopNavigation />
|
||||
<MobileSidebarSheet />
|
||||
<main className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
|
||||
<div className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
|
||||
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
|
||||
<ScrollArea className="h-full">
|
||||
<SideNavigation />
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
{children}
|
||||
{/* Content-only landmark: sidebar must stay outside so skip/Tab don't land in the nav */}
|
||||
<main id="main" tabIndex={-1} className="outline-hidden scroll-mt-12 min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</>
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,53 +0,0 @@
|
||||
import { Source_Code_Pro } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
|
||||
export const customFont = localFont({
|
||||
variable: '--font-custom',
|
||||
display: 'swap',
|
||||
fallback: ['Circular', 'custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
src: [
|
||||
{
|
||||
path: './CustomFont-Book.woff2',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BookItalic.woff2',
|
||||
weight: '400',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Medium.woff2',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BoldItalic.woff2',
|
||||
weight: '700',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Black.woff2',
|
||||
weight: '800',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BlackItalic.woff2',
|
||||
weight: '800',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export const sourceCodePro = Source_Code_Pro({
|
||||
subsets: ['latin'],
|
||||
fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'],
|
||||
variable: '--font-source-code-pro',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
})
|
||||
@@ -3,11 +3,11 @@ import '@/styles/globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
|
||||
import { customFont, sourceCodePro } from './fonts'
|
||||
import { Providers } from './Providers'
|
||||
import { Toaster } from './toaster'
|
||||
import { inter, manrope, sourceCodePro } from '@/lib/fonts'
|
||||
|
||||
const className = `${customFont.variable} ${sourceCodePro.variable}`
|
||||
const className = `${inter.variable} ${manrope.variable} ${sourceCodePro.variable}`
|
||||
|
||||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/design-system'
|
||||
|
||||
@@ -126,7 +126,7 @@ export default async function Layout({ children }: RootLayoutProps) {
|
||||
{/* [Danny]: This has to be an inline style tag here and not a separate component due to next/font */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
__html: `:root{--font-sans:${inter.style.fontFamily};--font-heading:${manrope.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
|
||||
@@ -64,7 +64,7 @@ const ColorPalette = () => {
|
||||
key={step * 100}
|
||||
type="button"
|
||||
onClick={() => handleCopy(reference)}
|
||||
className="group relative flex aspect-square w-full items-center justify-center rounded-sm border border-overlay/40 hover:scale-[1.05] focus-ring"
|
||||
className="group relative flex aspect-square w-full items-center justify-center rounded-sm border border-overlay/40 transition-transform hover:scale-[1.05] focus-ring"
|
||||
style={{ backgroundColor: reference }}
|
||||
title={reference}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { ComponentProps } from './component-props'
|
||||
import { SonnerExpandConfig } from './sonner-expand-config'
|
||||
|
||||
@@ -247,6 +247,11 @@ export const docsConfig: DocsConfig = {
|
||||
href: '/docs/fragments/single-value-field-array',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Skip to Content',
|
||||
href: '/docs/fragments/skip-to-content',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -116,11 +116,13 @@ Individual options inside of a group can be reached by arrow keys (↑ ↓ ←
|
||||
|
||||
### Jumping ahead
|
||||
|
||||
Some keyboard-navigable content may be contain hundreds or thousands of items. Help users jump to specific content with the following mitigation strategies:
|
||||
Some keyboard-navigable content may contain hundreds or thousands of items. Help users jump to specific content with:
|
||||
|
||||
- Search and filtering
|
||||
- Pagination or virtualization
|
||||
- “Jump to” shortcuts to skip ahead
|
||||
- Skip links and “jump to” shortcuts
|
||||
|
||||
Apps with persistent header and sidebar chrome should expose a skip link as the first focusable element. Use the shared [Skip to Content](fragments/skip-to-content) fragment which owns the component API, usage sample, and target landmark contract.
|
||||
|
||||
## Screen readers
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Button
|
||||
description: Displays a button or a component that looks like a button.
|
||||
description: Displays a button or a link that looks like a button.
|
||||
featured: true
|
||||
component: true
|
||||
---
|
||||
|
||||
@@ -13,7 +13,7 @@ source:
|
||||
<ComponentPreview name="label-demo" peekCode wide />
|
||||
|
||||
<Admonition
|
||||
variant="warning"
|
||||
type="warning"
|
||||
title="Do not use this Label component in a Form"
|
||||
>
|
||||
|
||||
|
||||
@@ -58,6 +58,21 @@ import { toast } from 'sonner'
|
||||
toast('Event has been created.')
|
||||
```
|
||||
|
||||
## When to use
|
||||
|
||||
Use a toast for short-lived, non-blocking feedback or to confirm an operation
|
||||
after its originating surface has closed or navigated away.
|
||||
|
||||
Do not use a toast as the only feedback for:
|
||||
|
||||
- Field validation. Use `FormMessage` or `FieldError` beside the field.
|
||||
- A failed submission that leaves the form or interstitial visible. Show a
|
||||
`role="alert"` message in destructive text near the actions.
|
||||
- A blocking or materially changed page state. Use an inline state component
|
||||
such as `Admonition` or `ErrorDisplay`.
|
||||
|
||||
Keep error copy specific and include the next step when it is not obvious.
|
||||
|
||||
## Expand
|
||||
|
||||
You can change the amount of toasts visible through the visibleToasts prop.
|
||||
|
||||
@@ -24,7 +24,7 @@ Avoid title-only Admonitions in new code. A callout with `title` should also inc
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
```
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Skip to Content
|
||||
description: Keyboard-accessible skip link that jumps past chrome to the main landmark.
|
||||
component: true
|
||||
fragment: true
|
||||
---
|
||||
|
||||
<ComponentPreview name="skip-to-content-demo" peekCode wide />
|
||||
|
||||
Keyboard-accessible skip link composed from [Button](../components/button) for apps with persistent header and sidebar chrome. Hidden until focused via Tab, then slides into view so users can jump past navigation to the main landmark.
|
||||
|
||||
The preview above shows the focused appearance. In product apps, the link stays off-screen until keyboard focus.
|
||||
|
||||
See [Accessibility](../accessibility#jumping-ahead) for more information on when to use skip links, and how they coexist alongside other navigation aids.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { SkipToContent } from 'ui-patterns/SkipToContent'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<SkipToContent href="#main" />
|
||||
|
||||
<main id="main" tabIndex={-1} className="scroll-mt-(--header-height) outline-hidden">
|
||||
{children}
|
||||
</main>
|
||||
```
|
||||
|
||||
Place the skip link at the root of the app chrome so Tab reaches it before navigation. The target landmark must be content only. Do not wrap a sidebar inside the same `<main>`, otherwise a Tab after skip will land in the sidebar navigation.
|
||||
|
||||
## Props
|
||||
|
||||
### `href`
|
||||
|
||||
Hash href to the main content landmark, e.g. `#main`.
|
||||
|
||||
### `children`
|
||||
|
||||
Link label. Defaults to `Skip to content`.
|
||||
|
||||
### `className`
|
||||
|
||||
Optional classes merged onto the positioning wrapper (useful for demos or layout overrides).
|
||||
|
||||
## Target landmark
|
||||
|
||||
Callers own the landmark the skip link points at:
|
||||
|
||||
- Matching `id`
|
||||
- `tabIndex={-1}` so Enter moves focus onto the landmark
|
||||
- `scroll-mt` when a sticky header is present
|
||||
- `outline-hidden` so the landmark has no visible focus ring. The subsequent Tab will land on the first interactive child
|
||||
@@ -200,6 +200,48 @@ for new states instead of inventing a parallel card style.
|
||||
Prefer one full-width primary action. A full-width text button is fine for a
|
||||
secondary action that still belongs in the flow.
|
||||
|
||||
### Action feedback
|
||||
|
||||
Match feedback to its scope:
|
||||
|
||||
- Use `FormMessage` or `FieldError` beside a field when that field needs to
|
||||
change.
|
||||
- Show a submission or action failure as simple destructive text below the
|
||||
actions. Separate it with a subtle divider when needed for composition. Keep
|
||||
the current account, selections, and actions visible so the user can retry.
|
||||
- Use `Admonition` when the whole interstitial is blocked or has materially
|
||||
changed state, such as an invalid link, wrong account, or partially completed
|
||||
setup.
|
||||
- Use a toast only for non-blocking feedback or a completed action whose
|
||||
originating surface is no longer visible. A toast must not be the only
|
||||
feedback for a failure the user needs to resolve on the current card.
|
||||
|
||||
```tsx
|
||||
{
|
||||
actionError && (
|
||||
<div className="mt-3 border-t border-muted pt-5">
|
||||
<p role="alert" className="text-center text-xs text-destructive text-balance">
|
||||
{actionError}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Clear stale action feedback when the user retries or changes a relevant
|
||||
selection. Error copy should say what failed and, when it is not obvious, what
|
||||
the user can do next.
|
||||
|
||||
<ComponentPreview
|
||||
name="connect-interstitial-action-error"
|
||||
description="Retryable action error shown beside the actions"
|
||||
align="start"
|
||||
className="p-0"
|
||||
padded={false}
|
||||
peekCode
|
||||
wide
|
||||
/>
|
||||
|
||||
## States
|
||||
|
||||
Keep loading, invalid, error, and success states inside the same card when the
|
||||
|
||||
@@ -42,7 +42,7 @@ Keep repeated-row validation in the form schema or shared validation helper, not
|
||||
|
||||
Build a custom row when the cells are mixed controls, such as an input paired with a `Select`.
|
||||
|
||||
## Best Practices
|
||||
## Best practices
|
||||
|
||||
1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`.
|
||||
|
||||
@@ -57,7 +57,7 @@ Build a custom row when the cells are mixed controls, such as an input paired wi
|
||||
|
||||
5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. Make sure you destructure `isDirty` from `form.formState` (see https://react-hook-form.com/docs/useform/formstate)
|
||||
|
||||
6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications.
|
||||
6. **Error handling**: Match feedback to its scope. Use `FormMessage` or `FieldError` for field validation. Show submission failures inline near the form actions when the user needs to retry or change something. Reserve toasts for non-blocking feedback or completed operations whose originating surface is no longer visible.
|
||||
|
||||
7. **Loading states**: Show loading states on submit buttons using the `loading` prop.
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ Supabase has a necessarily complex navigation system to handle multiple products
|
||||
|
||||
## Components
|
||||
|
||||
### NavMenu
|
||||
### [Nav Menu](../components/nav-menu)
|
||||
|
||||
A horizontal list of related views within a consistent PageLayout context, allowing for clearer page-level organisation. Activating a NavMenu item should trigger a URL change.
|
||||
|
||||
[NavMenu component guidelines](../components/nav-menu)
|
||||
### [Skip To Content](../fragments/skip-to-content)
|
||||
|
||||
Keyboard-accessible skip link for apps with persistent header and sidebar chrome. Hidden until focused via Tab, then slides into view so users can jump past navigation to the main landmark. See [Accessibility](../accessibility#jumping-ahead) for broader context.
|
||||
|
||||
## Page titles
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Inter, Manrope, Source_Code_Pro } from 'next/font/google'
|
||||
|
||||
export const manrope = Manrope({
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const sourceCodePro = Source_Code_Pro({
|
||||
subsets: ['latin'],
|
||||
fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'],
|
||||
variable: '--font-source-code-pro',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
})
|
||||
@@ -19,8 +19,8 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.1.1",
|
||||
"@tanstack/react-table": "catalog:",
|
||||
"contentlayer2": "0.4.6",
|
||||
"common": "workspace:*",
|
||||
"contentlayer2": "0.4.6",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "1.11.13",
|
||||
"eslint-config-supabase": "workspace:*",
|
||||
@@ -57,6 +57,7 @@
|
||||
"@types/lodash.template": "4.5.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"config": "workspace:*",
|
||||
"mdast-util-toc": "^6.1.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -66,7 +67,6 @@
|
||||
"tailwindcss": "catalog:",
|
||||
"tsconfig": "workspace:*",
|
||||
"tsx": "catalog:",
|
||||
"@typescript/native": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"unist-builder": "3.0.0"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ChartComposedActions() {
|
||||
showYAxis={true}
|
||||
YAxisProps={{
|
||||
tickFormatter: (value) => `${value}k`,
|
||||
width: 80,
|
||||
width: 36,
|
||||
}}
|
||||
isFullHeight={true}
|
||||
/>
|
||||
@@ -92,7 +92,7 @@ export default function ChartComposedActions() {
|
||||
showYAxis={true}
|
||||
YAxisProps={{
|
||||
tickFormatter: (value) => `${value}k`,
|
||||
width: 80,
|
||||
width: 36,
|
||||
}}
|
||||
isFullHeight={true}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function ComposedChartBasic() {
|
||||
showYAxis={true}
|
||||
YAxisProps={{
|
||||
tickFormatter: (value) => `${value}k`,
|
||||
width: 80,
|
||||
width: 36,
|
||||
}}
|
||||
isFullHeight={true}
|
||||
/>
|
||||
@@ -129,7 +129,7 @@ export default function ComposedChartBasic() {
|
||||
showYAxis={true}
|
||||
YAxisProps={{
|
||||
tickFormatter: (value) => `${value}k`,
|
||||
width: 80,
|
||||
width: 36,
|
||||
}}
|
||||
isFullHeight={true}
|
||||
/>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function ChartComposedTable() {
|
||||
showYAxis={true}
|
||||
YAxisProps={{
|
||||
tickFormatter: (value) => `${value}k`,
|
||||
width: 80,
|
||||
width: 36,
|
||||
}}
|
||||
isFullHeight={true}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionButtonSplitDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDescriptionOnly() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDestructive() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionDemo() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionSuccess() {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export default function AdmonitionWarning() {
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
const resetTemplate = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Button } from 'ui'
|
||||
|
||||
import {
|
||||
AccountRow,
|
||||
InterstitialShell,
|
||||
LogoPair,
|
||||
StripeLogo,
|
||||
SupabaseLogo,
|
||||
} from './connect-interstitial-shared'
|
||||
|
||||
export default function ConnectInterstitialActionError() {
|
||||
return (
|
||||
<InterstitialShell
|
||||
logo={<LogoPair left={<StripeLogo />} right={<SupabaseLogo />} />}
|
||||
title="Authorize Stripe Projects"
|
||||
description="This will create an organization on your behalf in Supabase"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AccountRow displayName="alex@example.com" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button variant="primary" block>
|
||||
Authorize Stripe Projects
|
||||
</Button>
|
||||
<Button variant="text" block>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="mt-3 border-t border-muted pt-5">
|
||||
<p role="alert" className="text-center text-xs text-destructive text-balance">
|
||||
Failed to authorize Stripe Projects. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InterstitialShell>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { AccountRow, InterstitialShell, SupabaseLogo } from './connect-interstitial-shared'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
const bucketId = 'user_avatars'
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
NavMenuItem,
|
||||
Switch,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SkipToContent } from 'ui-patterns/SkipToContent'
|
||||
|
||||
export default function SkipToContentDemo() {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-md border bg-studio">
|
||||
<div className="flex items-center border-b px-4 py-3 text-sm text-foreground-muted">
|
||||
Demo header / navigation
|
||||
</div>
|
||||
{/* Preview shows the focused appearance; production hides until Tab */}
|
||||
<SkipToContent href="#skip-demo-main" className="relative left-3 top-2 translate-y-0" />
|
||||
<main id="skip-demo-main" tabIndex={-1} className="outline-hidden p-6 pt-2">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Main content landmark. In a real app, Tab once to reveal the skip link, then Enter to jump
|
||||
here.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1351,6 +1351,11 @@ export const examples: Registry = [
|
||||
type: 'components:example',
|
||||
files: ['example/info-tooltip-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'skip-to-content-demo',
|
||||
type: 'components:example',
|
||||
files: ['example/skip-to-content-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'page-container-demo',
|
||||
type: 'components:example',
|
||||
@@ -1403,6 +1408,11 @@ export const examples: Registry = [
|
||||
type: 'components:example',
|
||||
files: ['example/connect-interstitial-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'connect-interstitial-action-error',
|
||||
type: 'components:example',
|
||||
files: ['example/connect-interstitial-action-error.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'connect-interstitial-logo-pair',
|
||||
type: 'components:example',
|
||||
|
||||
@@ -9,9 +9,30 @@
|
||||
@source './../../../packages/ui/src/**/*.{tsx,ts,js}';
|
||||
@source './../../../packages/ui-patterns/src/**/*.{tsx,ts,js}';
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
var(--font-inter), Inter, Helvetica Neue, Helvetica, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-heading: var(--font-manrope, var(--font-sans));
|
||||
--font-mono: var(--font-source-code-pro), 'Source Code Pro', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* added to get max-w-site */
|
||||
--container-site: 128rem;
|
||||
/* font sizing and weights optimized for Inter */
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.125rem;
|
||||
--text-2xl: 1.375rem;
|
||||
--text-3xl: 1.75rem;
|
||||
--text-4xl: 2.125rem;
|
||||
--text-5xl: 2.875rem;
|
||||
--text-6xl: 3.625rem;
|
||||
--text-7xl: 4.375rem;
|
||||
--text-8xl: 5.875rem;
|
||||
--text-9xl: 7.875rem;
|
||||
--font-weight-normal: 450;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -30,9 +51,7 @@
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
@@ -40,13 +59,35 @@
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
body {
|
||||
@apply bg-default text-foreground;
|
||||
@apply bg-default text-foreground font-normal;
|
||||
/* font-feature-settings: "rlig" 1, "calt" 1; */
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code,
|
||||
.code-content,
|
||||
pre,
|
||||
kbd,
|
||||
samp,
|
||||
.font-mono {
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
--text-9xl: 8rem;
|
||||
--font-weight-normal: 400;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -76,6 +117,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
h1:not(.font-mono),
|
||||
h2:not(.font-mono),
|
||||
h3:not(.font-mono),
|
||||
h4:not(.font-mono),
|
||||
h5:not(.font-mono),
|
||||
h6:not(.font-mono),
|
||||
.h1:not(.font-mono),
|
||||
.h2:not(.font-mono),
|
||||
.h3:not(.font-mono),
|
||||
.h4:not(.font-mono),
|
||||
.h5:not(.font-mono),
|
||||
.h6:not(.font-mono) {
|
||||
@apply font-heading font-semibold;
|
||||
}
|
||||
|
||||
.rdg-cell {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
@@ -29,8 +29,11 @@ yarn-error.log*
|
||||
public/sitemap.xml
|
||||
# Per-source llms files (generated by build:llms, served by www)
|
||||
public/llms/
|
||||
# Generated guide and reference markdown files
|
||||
public/markdown/
|
||||
# Generated guide and reference markdown files. manifest.json is committed
|
||||
# with a placeholder empty array so middleware.ts's import always resolves,
|
||||
# even when build:markdown hasn't run (e.g. in local dev, which skips it).
|
||||
public/markdown/*
|
||||
!public/markdown/manifest.json
|
||||
public/docs.tar.gz
|
||||
public/docs/
|
||||
|
||||
@@ -48,6 +51,8 @@ public/docs/
|
||||
/content/guides/deployment/terraform/tutorial.mdx
|
||||
/content/guides/deployment/ci/
|
||||
/content/guides/ai/python/
|
||||
/content/guides/database/extensions/wrappers/*
|
||||
!/content/guides/database/extensions/wrappers/overview.mdx
|
||||
|
||||
# Downloaded TypeDoc dumps under spec/reference/<lib>/<ver>/. Regenerated by
|
||||
# `cd apps/docs/spec && make download.tsdoc.v2`. Hand-authored files in the
|
||||
|
||||
@@ -191,8 +191,7 @@ Choose the appropriate `type` for your admonition:
|
||||
- `danger`: Warn about actions or conditions that could cause data loss, expose sensitive data, or create another severe and difficult-to-reverse outcome. State the consequence first, and then explain how to avoid it.
|
||||
- `deprecation`: Identify a deprecated feature or behavior. State how the change affects the reader, and then provide the supported alternative or migration path.
|
||||
- `caution`: Warn about behavior that could cause bugs, failed operations, unexpected results, or serious inconvenience but doesn't rise to the severity of `danger`.
|
||||
- `tip`: Share an optional shortcut, optimization, or best practice that helps the reader complete the task more effectively. The main procedure must still work without it.
|
||||
- `note`: Highlight an important prerequisite, constraint, or clarification that doesn't represent a risk. If the information is essential to completing a step, include it in the procedure instead.
|
||||
- `note`: Highlight an important prerequisite, constraint, clarification, or optional shortcut that doesn't represent a risk. If the information is essential to completing a step, include it in the procedure instead.
|
||||
|
||||
```
|
||||
<Admonition type="note" title="Optional title">
|
||||
|
||||
@@ -17,7 +17,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE
|
||||
[supabase.com/docs](https://supabase.com/docs) is a Next.js site. You can get setup by following the same steps for all of our other Next.js projects:
|
||||
|
||||
1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md)
|
||||
2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false`
|
||||
2. If you work at Supabase, from `apps/docs` run `pnpm run dev:secrets:pull` to write internal env vars to `.env.local`. If you're a community member, create `apps/docs/.env.local` and add this line: `NEXT_PUBLIC_IS_PLATFORM=false`
|
||||
3. Start the local docs site by navigating to `/apps/docs` and running `pnpm run dev`
|
||||
4. Visit http://localhost:3001/docs in your browser - don't forget to append the `/docs` to the end
|
||||
5. Your local site should look exactly like [https://supabase.com/docs](https://supabase.com/docs)
|
||||
|
||||
@@ -75,12 +75,11 @@ For content that requires progressive disclosure:
|
||||
|
||||
### Admonition
|
||||
|
||||
For extra information that doesn't fit into the main flow. There are 5 supported types of admonitions:
|
||||
For extra information that doesn't fit into the main flow, you can use the following types of admonitions:
|
||||
|
||||
- `danger` to warn the user about any missteps that could cause data loss or data leaks
|
||||
- `deprecation` to notify the user about features that are (or will soon be) deprecated
|
||||
- `caution` to warn about anything that could cause a bug or serious user inconvenience
|
||||
- `tip` to point out helpful but optional actions
|
||||
- `note` for anything else
|
||||
|
||||
Leave a blank line between the admonition tag and the contained content. This will prevent Prettier from trying to break the lines within the content.
|
||||
@@ -104,15 +103,9 @@ You should make sure you don't set this up wrong.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
In certain cases, you may want to do this.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Additional helpful information.
|
||||
In certain cases, you may want to do this.
|
||||
|
||||
</Admonition>
|
||||
```
|
||||
@@ -135,15 +128,9 @@ You should make sure you don't set this up wrong.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
In certain cases, you may want to do this.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Additional helpful information.
|
||||
In certain cases, you may want to do this.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -1,185 +1,13 @@
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { genGuideMeta } from '~/features/docs/GuidesMdx.utils'
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { TabPanel, Tabs } from '~/features/ui/Tabs'
|
||||
import { linkTransform, UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
|
||||
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
|
||||
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
|
||||
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
|
||||
import { getGitHubFileContents, octokit, OCTOKIT_RETRY_OPTIONS } from '~/lib/octokit'
|
||||
import { SerializeOptions } from '~/types/next-mdx-remote-serialize'
|
||||
import { capitalize } from 'lodash-es'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import { Heading } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
// We fetch these docs at build time from an external repo
|
||||
const org = 'supabase'
|
||||
const repo = 'splinter'
|
||||
const branch = 'main'
|
||||
const docsDir = 'docs'
|
||||
|
||||
const meta = {
|
||||
title: 'Performance and Security Advisors',
|
||||
subtitle: 'Check your database for performance and security issues',
|
||||
}
|
||||
|
||||
const generateMetadata = genGuideMeta(() => ({
|
||||
pathname: '/guides/database/database-advisors',
|
||||
meta,
|
||||
}))
|
||||
|
||||
const editLink = newEditLink('supabase/splinter/tree/main/docs')
|
||||
|
||||
const markdownIntro = `
|
||||
You can use the Database Performance and Security Advisors to check your database for issues such as missing indexes and improperly set-up RLS policies.
|
||||
|
||||
## Using the Advisors
|
||||
|
||||
In the dashboard, navigate to [Security Advisor](https://supabase.com/dashboard/project/_/database/security-advisor) and [Performance Advisor](https://supabase.com/dashboard/project/_/database/performance-advisor) under Database. The advisors run automatically. You can also manually rerun them after you've resolved issues.
|
||||
`.trim()
|
||||
|
||||
const getBasename = (path: string) => path.split('/').at(-1)!.replace(/\.md$/, '')
|
||||
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
|
||||
import { genGuideMeta, getGuidesMarkdown } from '~/features/docs/GuidesMdx.utils'
|
||||
|
||||
const DatabaseAdvisorDocs = async () => {
|
||||
let lints: Awaited<ReturnType<typeof getLints>>['lints'] = []
|
||||
let lintsList: Awaited<ReturnType<typeof getLints>>['lintsList'] = []
|
||||
let fetchError: Error | null = null
|
||||
const data = await getGuidesMarkdown(['database', 'database-advisors'])
|
||||
|
||||
try {
|
||||
const data = await getLints()
|
||||
lints = data.lints
|
||||
lintsList = data.lintsList
|
||||
} catch (error) {
|
||||
fetchError = error instanceof Error ? error : new Error('Unknown error fetching advisor docs')
|
||||
console.error('[database-advisors] Failed to fetch advisor docs from GitHub', fetchError)
|
||||
}
|
||||
|
||||
const options = {
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkMkDocsAdmonition, remarkPyMdownTabs, [removeTitle, meta.title]],
|
||||
rehypePlugins: [[linkTransform, urlTransform(lintsList)], rehypeSlug],
|
||||
},
|
||||
} as SerializeOptions
|
||||
|
||||
return (
|
||||
<GuideTemplate meta={meta} editLink={editLink} pathname="/guides/database/database-advisors">
|
||||
<MDXRemoteBase source={markdownIntro} />
|
||||
<Heading tag="h2">Available checks</Heading>
|
||||
|
||||
{fetchError ? (
|
||||
<Admonition type="note" title="Couldn’t load the full Advisor library">
|
||||
We fetch remediation guides straight from the <code>supabase/splinter</code> repository
|
||||
during the build. GitHub timed out just now, so we’re showing the overview only.
|
||||
<br />
|
||||
<br />
|
||||
You can check back in a few minutes or browse the
|
||||
{` `}
|
||||
<a
|
||||
className="underline decoration-dashed underline-offset-2"
|
||||
href="https://github.com/supabase/splinter/tree/main/docs"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
latest Markdown on GitHub (opens in a new tab)
|
||||
</a>
|
||||
.
|
||||
</Admonition>
|
||||
) : (
|
||||
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:m-0!" queryGroup="lint">
|
||||
{lints.map((lint) => (
|
||||
<TabPanel
|
||||
key={lint.path}
|
||||
id={lint.path}
|
||||
label={capitalize(getBasename(lint.path).replace(/_/g, ' '))}
|
||||
>
|
||||
<section id={getBasename(lint.path)}>
|
||||
<MDXRemoteBase source={lint.content} options={options} />
|
||||
</section>
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</GuideTemplate>
|
||||
)
|
||||
return <GuideTemplate {...data!} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The GitHub repo uses relative links, which don't lead to the right locations
|
||||
* in docs.
|
||||
*
|
||||
* @param url The original link, as written in the Markdown file
|
||||
* @returns The rewritten link
|
||||
*/
|
||||
const urlTransform: (lints: Array<{ path: string }>) => UrlTransformFunction = (lints) => (url) => {
|
||||
try {
|
||||
const placeholderHostname = 'placeholder'
|
||||
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
|
||||
|
||||
// Don't modify a url with a FQDN or a url that's only a hash
|
||||
if (hostname !== placeholderHostname || pathname === '/') {
|
||||
return url
|
||||
}
|
||||
|
||||
const relativePath = getBasename(pathname)
|
||||
const section = lints.find(({ path }) => path === relativePath)
|
||||
|
||||
if (section) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('lint', relativePath)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// If we don't have this page in our docs, link to GitHub repo
|
||||
return `https://github.com/${org}/${repo}/blob/${branch}${pathname}${hash}`
|
||||
} catch (err) {
|
||||
console.error('Error transforming markdown URL', err)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch lint remediation Markdown from external repo
|
||||
*/
|
||||
const getLints = async () => {
|
||||
const response = await octokit().request('GET /repos/{owner}/{repo}/contents/{path}', {
|
||||
owner: org,
|
||||
repo: repo,
|
||||
path: docsDir,
|
||||
ref: branch,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
request: OCTOKIT_RETRY_OPTIONS,
|
||||
})
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${org}/${repo}/contents/${docsDir} docs from GitHub: ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.data)) {
|
||||
throw Error(
|
||||
'Reading a directory, not a file. Should not reach this, solely to appease Typescript.'
|
||||
)
|
||||
}
|
||||
|
||||
const lintsList = response.data.filter(({ path }) => /docs\/\d+.+\.md$/.test(path))
|
||||
|
||||
const lints = await Promise.all(
|
||||
lintsList.map(async ({ path }) => {
|
||||
const content = await getGitHubFileContents({ org, repo, path, branch })
|
||||
|
||||
return {
|
||||
path: getBasename(path),
|
||||
content,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return { lints, lintsList }
|
||||
}
|
||||
const generateMetadata = genGuideMeta(() => getGuidesMarkdown(['database', 'database-advisors']))
|
||||
|
||||
export default DatabaseAdvisorDocs
|
||||
export { generateMetadata }
|
||||
@@ -1,345 +1,15 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, relative } from 'node:path'
|
||||
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
|
||||
import {
|
||||
genGuideMeta,
|
||||
genGuidesStaticParams,
|
||||
removeRedundantH1,
|
||||
getGuidesMarkdown,
|
||||
} from '~/features/docs/GuidesMdx.utils'
|
||||
import { newEditLink } from '~/features/helpers.edit-link'
|
||||
import { Guide, GuideArticle, GuideFooter, GuideHeader, GuideMdxContent } from '~/features/ui/guide'
|
||||
// End of third-party imports
|
||||
|
||||
import { getEmptyArray } from '~/features/helpers.fn'
|
||||
import { IS_DEV } from '~/lib/constants'
|
||||
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
|
||||
import { linkTransform, type UrlTransformFunction } from '~/lib/mdx/plugins/rehypeLinkTransform'
|
||||
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
|
||||
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
|
||||
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
|
||||
import { getGitHubFileContents, octokit } from '~/lib/octokit'
|
||||
import type { SerializeOptions } from '~/types/next-mdx-remote-serialize'
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import matter from 'gray-matter'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import emoji from 'remark-emoji'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
// We fetch these docs at build time from an external repo
|
||||
const org = 'supabase'
|
||||
const repo = 'wrappers'
|
||||
const docsDir = 'docs/catalog'
|
||||
const externalSite = 'https://supabase.github.io/wrappers'
|
||||
|
||||
type DocsTagsQueryResponse = {
|
||||
repository: {
|
||||
refs: {
|
||||
nodes: { name: string }[] | null
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const docsTagsQuery = `
|
||||
query DocsTagsQuery($owner: String!, $name: String!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
refs(
|
||||
refPrefix: "refs/tags/",
|
||||
orderBy: { field: TAG_COMMIT_DATE, direction: DESC },
|
||||
first: 5,
|
||||
after: $after
|
||||
) {
|
||||
nodes { name }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
async function getLatestDocsTag(after: string | null = null): Promise<string | null> {
|
||||
try {
|
||||
/**
|
||||
* We use GraphQL as it's the only way to use `orderBy` on Github API.
|
||||
*/
|
||||
const {
|
||||
repository: {
|
||||
refs: {
|
||||
nodes,
|
||||
pageInfo: { hasNextPage, endCursor },
|
||||
},
|
||||
},
|
||||
} = await octokit().graphql<DocsTagsQueryResponse>(docsTagsQuery, {
|
||||
owner: org,
|
||||
name: repo,
|
||||
after,
|
||||
})
|
||||
|
||||
return (
|
||||
nodes?.find(({ name }) => /^docs_v\d+\.\d+\.\d+/.test(name))?.name ??
|
||||
(hasNextPage && endCursor ? await getLatestDocsTag(endCursor) : null)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error fetching docs tags for wrappers federated pages: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Each external docs page is mapped to a local page
|
||||
const pageMap = [
|
||||
{
|
||||
slug: 'airtable',
|
||||
meta: {
|
||||
title: 'Airtable',
|
||||
dashboardIntegrationPath: 'airtable_wrapper',
|
||||
},
|
||||
remoteFile: 'airtable.md',
|
||||
},
|
||||
{
|
||||
slug: 'auth0',
|
||||
meta: {
|
||||
title: 'Auth0',
|
||||
dashboardIntegrationPath: 'auth0_wrapper',
|
||||
},
|
||||
remoteFile: 'auth0.md',
|
||||
},
|
||||
{
|
||||
slug: 'bigquery',
|
||||
meta: {
|
||||
title: 'BigQuery',
|
||||
dashboardIntegrationPath: 'bigquery_wrapper',
|
||||
},
|
||||
remoteFile: 'bigquery.md',
|
||||
},
|
||||
{
|
||||
slug: 'cal',
|
||||
meta: {
|
||||
title: 'Cal.com',
|
||||
dashboardIntegrationPath: 'cal_wrapper',
|
||||
},
|
||||
remoteFile: 'cal.md',
|
||||
},
|
||||
{
|
||||
slug: 'calendly',
|
||||
meta: {
|
||||
title: 'Calendly',
|
||||
dashboardIntegrationPath: 'calendly_wrapper',
|
||||
},
|
||||
remoteFile: 'calendly.md',
|
||||
},
|
||||
{
|
||||
slug: 'clerk',
|
||||
meta: {
|
||||
title: 'Clerk',
|
||||
dashboardIntegrationPath: 'clerk_wrapper',
|
||||
},
|
||||
remoteFile: 'clerk.md',
|
||||
},
|
||||
{
|
||||
slug: 'clickhouse',
|
||||
meta: {
|
||||
title: 'ClickHouse',
|
||||
dashboardIntegrationPath: 'clickhouse_wrapper',
|
||||
},
|
||||
remoteFile: 'clickhouse.md',
|
||||
},
|
||||
{
|
||||
slug: 'cloudflare-d1',
|
||||
meta: {
|
||||
title: 'Cloudflare D1',
|
||||
dashboardIntegrationPath: 'cfd1_wrapper',
|
||||
},
|
||||
remoteFile: 'cfd1.md',
|
||||
},
|
||||
{
|
||||
slug: 'cognito',
|
||||
meta: {
|
||||
title: 'AWS Cognito',
|
||||
dashboardIntegrationPath: 'cognito_wrapper',
|
||||
},
|
||||
remoteFile: 'cognito.md',
|
||||
},
|
||||
{
|
||||
slug: 'duckdb',
|
||||
meta: {
|
||||
title: 'DuckDB',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'duckdb.md',
|
||||
},
|
||||
{
|
||||
slug: 'dynamodb',
|
||||
meta: {
|
||||
title: 'AWS DynamoDB',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'dynamodb.md',
|
||||
},
|
||||
{
|
||||
slug: 'firebase',
|
||||
meta: {
|
||||
title: 'Firebase',
|
||||
dashboardIntegrationPath: 'firebase_wrapper',
|
||||
},
|
||||
remoteFile: 'firebase.md',
|
||||
},
|
||||
{
|
||||
slug: 'gravatar',
|
||||
meta: {
|
||||
title: 'Gravatar',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'gravatar.md',
|
||||
},
|
||||
{
|
||||
slug: 'hubspot',
|
||||
meta: {
|
||||
title: 'HubSpot',
|
||||
dashboardIntegrationPath: 'hubspot_wrapper',
|
||||
},
|
||||
remoteFile: 'hubspot.md',
|
||||
},
|
||||
{
|
||||
slug: 'iceberg',
|
||||
meta: {
|
||||
title: 'Iceberg',
|
||||
dashboardIntegrationPath: 'iceberg_wrapper',
|
||||
},
|
||||
remoteFile: 'iceberg.md',
|
||||
},
|
||||
{
|
||||
slug: 'infura',
|
||||
meta: {
|
||||
title: 'Infura',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'infura.md',
|
||||
},
|
||||
{
|
||||
slug: 'logflare',
|
||||
meta: {
|
||||
title: 'Logflare',
|
||||
dashboardIntegrationPath: 'logflare_wrapper',
|
||||
},
|
||||
remoteFile: 'logflare.md',
|
||||
},
|
||||
{
|
||||
slug: 'mongodb',
|
||||
meta: {
|
||||
title: 'MongoDB',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'mongodb.md',
|
||||
},
|
||||
{
|
||||
slug: 'mssql',
|
||||
meta: {
|
||||
title: 'MSSQL',
|
||||
dashboardIntegrationPath: 'mssql_wrapper',
|
||||
},
|
||||
remoteFile: 'mssql.md',
|
||||
},
|
||||
{
|
||||
slug: 'mysql',
|
||||
meta: {
|
||||
title: 'MySQL',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'mysql.md',
|
||||
},
|
||||
{
|
||||
slug: 'notion',
|
||||
meta: {
|
||||
title: 'Notion',
|
||||
dashboardIntegrationPath: 'notion_wrapper',
|
||||
},
|
||||
remoteFile: 'notion.md',
|
||||
},
|
||||
{
|
||||
slug: 'openapi',
|
||||
meta: {
|
||||
title: 'OpenAPI',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'openapi.md',
|
||||
},
|
||||
{
|
||||
slug: 'orb',
|
||||
meta: {
|
||||
title: 'Orb',
|
||||
dashboardIntegrationPath: 'orb_wrapper',
|
||||
},
|
||||
remoteFile: 'orb.md',
|
||||
},
|
||||
{
|
||||
slug: 'paddle',
|
||||
meta: {
|
||||
title: 'Paddle',
|
||||
dashboardIntegrationPath: 'paddle_wrapper',
|
||||
},
|
||||
remoteFile: 'paddle.md',
|
||||
},
|
||||
{
|
||||
slug: 'redis',
|
||||
meta: {
|
||||
title: 'Redis',
|
||||
dashboardIntegrationPath: 'redis_wrapper',
|
||||
},
|
||||
remoteFile: 'redis.md',
|
||||
},
|
||||
{
|
||||
slug: 's3',
|
||||
meta: {
|
||||
title: 'AWS S3',
|
||||
dashboardIntegrationPath: 's3_wrapper',
|
||||
},
|
||||
remoteFile: 's3.md',
|
||||
},
|
||||
{
|
||||
slug: 's3_vectors',
|
||||
meta: {
|
||||
title: 'AWS S3 Vectors',
|
||||
dashboardIntegrationPath: 's3_vectors_wrapper',
|
||||
},
|
||||
remoteFile: 's3vectors.md',
|
||||
},
|
||||
{
|
||||
slug: 'shopify',
|
||||
meta: {
|
||||
title: 'Shopify',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'shopify.md',
|
||||
},
|
||||
{
|
||||
slug: 'slack',
|
||||
meta: {
|
||||
title: 'Slack',
|
||||
dashboardIntegrationPath: undefined,
|
||||
},
|
||||
remoteFile: 'slack.md',
|
||||
},
|
||||
{
|
||||
slug: 'snowflake',
|
||||
meta: {
|
||||
title: 'Snowflake',
|
||||
dashboardIntegrationPath: 'snowflake_wrapper',
|
||||
},
|
||||
remoteFile: 'snowflake.md',
|
||||
},
|
||||
{
|
||||
slug: 'stripe',
|
||||
meta: {
|
||||
title: 'Stripe',
|
||||
dashboardIntegrationPath: 'stripe_wrapper',
|
||||
},
|
||||
remoteFile: 'stripe.md',
|
||||
},
|
||||
]
|
||||
|
||||
interface Params {
|
||||
slug?: string[]
|
||||
}
|
||||
type Params = { slug?: string[] }
|
||||
|
||||
const WrappersDocs = async (props: { params: Promise<Params> }) => {
|
||||
if (!isFeatureEnabled('docs:fdw')) {
|
||||
@@ -347,191 +17,18 @@ const WrappersDocs = async (props: { params: Promise<Params> }) => {
|
||||
}
|
||||
|
||||
const params = await props.params
|
||||
const { isExternal, meta, assetsBaseUrl, ...data } = await getContent(params)
|
||||
const slug = ['database', 'extensions', 'wrappers', ...(params.slug ?? [])]
|
||||
const data = await getGuidesMarkdown(slug)
|
||||
|
||||
// Create a combined URL transformer that handles both regular URLs and asset URLs
|
||||
const combinedUrlTransformer: UrlTransformFunction = (url, node) => {
|
||||
// First try assets URL transformation (starts with ../assets/)
|
||||
const transformedUrl = assetUrlTransform(url, assetsBaseUrl)
|
||||
|
||||
// If URL wasn't changed proceed with regular URL transformation
|
||||
if (transformedUrl === url) {
|
||||
return urlTransform(url, node)
|
||||
}
|
||||
|
||||
return transformedUrl
|
||||
}
|
||||
|
||||
const options = isExternal
|
||||
? ({
|
||||
mdxOptions: {
|
||||
remarkPlugins: [
|
||||
remarkMkDocsAdmonition,
|
||||
emoji,
|
||||
remarkPyMdownTabs,
|
||||
[removeTitle, meta.title],
|
||||
],
|
||||
rehypePlugins: [[linkTransform, combinedUrlTransformer], rehypeSlug],
|
||||
},
|
||||
} as SerializeOptions)
|
||||
: undefined
|
||||
|
||||
const dashboardIntegrationURL = getDashboardIntegrationURL(meta.dashboardIntegrationPath)
|
||||
|
||||
return (
|
||||
<Guide meta={meta}>
|
||||
<GuideArticle>
|
||||
<GuideHeader />
|
||||
|
||||
{dashboardIntegrationURL && (
|
||||
<Admonition type="tip" className="mb-4">
|
||||
<p>You can enable the {meta.title} wrapper right from the Supabase dashboard.</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={dashboardIntegrationURL} className="no-underline">
|
||||
Open wrapper in dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<GuideMdxContent content={data.content} mdxOptions={options} />
|
||||
|
||||
<GuideFooter editLink={data.editLink} />
|
||||
</GuideArticle>
|
||||
</Guide>
|
||||
)
|
||||
return <GuideTemplate {...data!} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch markdown from external repo
|
||||
*/
|
||||
const getContent = async (params: Params) => {
|
||||
const federatedPage = pageMap.find(
|
||||
({ slug }) => params && slug && params.slug && slug === params.slug.at(0)
|
||||
)
|
||||
|
||||
let isExternal: boolean
|
||||
let meta: any
|
||||
let content: string
|
||||
let editLink: string
|
||||
let assetsBaseUrl: string = ''
|
||||
|
||||
if (!federatedPage) {
|
||||
isExternal = false
|
||||
editLink = `supabase/supabase/apps/docs/content/guides/database/extensions/wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}.mdx`
|
||||
const rawContent = await readFile(
|
||||
join(
|
||||
GUIDES_DIRECTORY,
|
||||
'database',
|
||||
'extensions',
|
||||
`wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}.mdx`
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
;({ data: meta, content } = matter(rawContent))
|
||||
if (!isValidGuideFrontmatter(meta)) {
|
||||
throw Error(`Expected valid frontmatter, got ${JSON.stringify(meta, null, 2)}`)
|
||||
}
|
||||
} else {
|
||||
isExternal = true
|
||||
let remoteFile: string
|
||||
;({ remoteFile, meta } = federatedPage)
|
||||
|
||||
const tag = await getLatestDocsTag()
|
||||
|
||||
if (!tag) {
|
||||
throw new Error('No latest docs tag found for federated wrappers pages')
|
||||
}
|
||||
|
||||
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
|
||||
|
||||
let rawContent = await getGitHubFileContents({
|
||||
org,
|
||||
repo,
|
||||
path: `${docsDir}/${remoteFile}`,
|
||||
branch: tag,
|
||||
})
|
||||
|
||||
assetsBaseUrl = `https://raw.githubusercontent.com/${org}/${repo}/${tag}/docs/assets/`
|
||||
|
||||
const { content: contentWithoutFrontmatter } = matter(rawContent)
|
||||
content = removeRedundantH1(contentWithoutFrontmatter)
|
||||
}
|
||||
|
||||
return {
|
||||
pathname:
|
||||
`/guides/database/extensions/wrappers${params.slug?.length ? `/${params.slug.join('/')}` : ''}` satisfies `/${string}`,
|
||||
isExternal,
|
||||
editLink: newEditLink(editLink),
|
||||
meta,
|
||||
content,
|
||||
assetsBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const getDashboardIntegrationURL = (wrapperPath?: string) => {
|
||||
return wrapperPath
|
||||
? `https://supabase.com/dashboard/project/_/integrations/${wrapperPath}/overview`
|
||||
: null
|
||||
}
|
||||
|
||||
const assetUrlTransform = (url: string, baseUrl: string): string => {
|
||||
const assetPattern = /(\.\.\/)+assets\//
|
||||
|
||||
if (assetPattern.test(url)) {
|
||||
return url.replace(assetPattern, baseUrl)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const urlTransform: UrlTransformFunction = (url) => {
|
||||
try {
|
||||
const externalSiteUrl = new URL(externalSite)
|
||||
|
||||
const placeholderHostname = 'placeholder'
|
||||
const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`)
|
||||
|
||||
// Don't modify a url with a FQDN or a url that's only a hash
|
||||
if (hostname !== placeholderHostname || pathname === '/') {
|
||||
return url
|
||||
}
|
||||
const relativePage = (
|
||||
pathname.endsWith('.md')
|
||||
? pathname.replace(/\.md$/, '')
|
||||
: relative(externalSiteUrl.pathname, pathname)
|
||||
).replace(/^\//, '')
|
||||
|
||||
const page = pageMap.find(({ remoteFile }) => `${relativePage}.md` === remoteFile)
|
||||
|
||||
// If we have a mapping for this page, use the mapped path
|
||||
if (page) {
|
||||
return page.slug + hash
|
||||
}
|
||||
|
||||
// If we don't have this page in our docs, link to original docs
|
||||
return `${externalSite}/${relativePage}${hash}`
|
||||
} catch (err) {
|
||||
console.error('Error transforming markdown URL', err)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const generateStaticParams = async () => {
|
||||
if (IS_DEV) {
|
||||
return []
|
||||
}
|
||||
|
||||
const mdxPaths = await genGuidesStaticParams('database/extensions/wrappers')()
|
||||
const federatedPaths = pageMap.map(({ slug }) => ({
|
||||
slug: [slug],
|
||||
}))
|
||||
|
||||
return [...mdxPaths, ...federatedPaths]
|
||||
}
|
||||
|
||||
const generateMetadata = genGuideMeta(getContent)
|
||||
const generateStaticParams = !IS_DEV
|
||||
? genGuidesStaticParams('database/extensions/wrappers')
|
||||
: getEmptyArray
|
||||
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
|
||||
getGuidesMarkdown(['database', 'extensions', 'wrappers', ...(params.slug ?? [])])
|
||||
)
|
||||
|
||||
export default WrappersDocs
|
||||
export { generateMetadata, generateStaticParams }
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getAiSkillsImpl } from './AiSkills.utils'
|
||||
|
||||
const { readFileMock } = vi.hoisted(() => ({
|
||||
readFileMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: readFileMock,
|
||||
}))
|
||||
|
||||
describe('getAiSkillsImpl', () => {
|
||||
beforeEach(() => {
|
||||
readFileMock.mockReset()
|
||||
})
|
||||
|
||||
it('parses the generated skills JSON', async () => {
|
||||
const skills = [
|
||||
{
|
||||
name: 'supabase',
|
||||
description: 'Work with Supabase',
|
||||
installCommand: 'npx skills add supabase/agent-skills --skill supabase',
|
||||
},
|
||||
{
|
||||
name: 'supabase-postgres-best-practices',
|
||||
description: 'Postgres best practices',
|
||||
installCommand:
|
||||
'npx skills add supabase/agent-skills --skill supabase-postgres-best-practices',
|
||||
},
|
||||
]
|
||||
readFileMock.mockResolvedValue(JSON.stringify(skills))
|
||||
|
||||
await expect(getAiSkillsImpl()).resolves.toEqual(skills)
|
||||
})
|
||||
|
||||
it('propagates errors reading the generated file', async () => {
|
||||
readFileMock.mockRejectedValue(new Error('ENOENT'))
|
||||
|
||||
await expect(getAiSkillsImpl()).rejects.toThrow('ENOENT')
|
||||
})
|
||||
|
||||
it('throws when the generated JSON is not an array', async () => {
|
||||
readFileMock.mockResolvedValue(JSON.stringify({ name: 'supabase' }))
|
||||
|
||||
await expect(getAiSkillsImpl()).rejects.toThrow('Malformed ai-skills.json')
|
||||
})
|
||||
|
||||
it('throws when an entry is missing required string fields', async () => {
|
||||
readFileMock.mockResolvedValue(JSON.stringify([{ name: 'supabase', description: 'x' }]))
|
||||
|
||||
await expect(getAiSkillsImpl()).rejects.toThrow('Malformed ai-skills.json')
|
||||
})
|
||||
})
|
||||
@@ -9,9 +9,25 @@ interface SkillSummary {
|
||||
installCommand: string
|
||||
}
|
||||
|
||||
async function getAiSkillsImpl(): Promise<SkillSummary[]> {
|
||||
function isSkillSummary(value: unknown): value is SkillSummary {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as SkillSummary).name === 'string' &&
|
||||
typeof (value as SkillSummary).description === 'string' &&
|
||||
typeof (value as SkillSummary).installCommand === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAiSkillsImpl(): Promise<SkillSummary[]> {
|
||||
const raw = await readFile(join(GENERATED_DIRECTORY, 'ai-skills.json'), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
|
||||
if (!Array.isArray(parsed) || !parsed.every(isSkillSummary)) {
|
||||
throw new Error('Malformed ai-skills.json: expected an array of SkillSummary objects')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const getAiSkills = cache(getAiSkillsImpl)
|
||||
@@ -0,0 +1,31 @@
|
||||
// Guards against the docs GitHub App losing access to supabase/agent-skills,
|
||||
// which 404s silently and renders an empty table.
|
||||
import { load } from 'cheerio'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Override to target a preview deploy or localhost; defaults to production.
|
||||
const DOCS_BASE_URL = process.env.DOCS_SMOKE_URL ?? 'https://supabase.com'
|
||||
const AI_SKILLS_URL = `${DOCS_BASE_URL.replace(/\/$/, '')}/docs/guides/ai-tools/ai-skills`
|
||||
|
||||
describe('prod smoke test: agent skills load on the AI Skills page', () => {
|
||||
it('renders the skills table with at least one skill and no fallback', async () => {
|
||||
const result = await fetch(AI_SKILLS_URL, { signal: AbortSignal.timeout(30_000) })
|
||||
expect(result.status).toBe(200)
|
||||
|
||||
const html = await result.text()
|
||||
expect(html).not.toContain('Unable to load AI skills at the moment.')
|
||||
|
||||
// The install command only appears on real skill rows.
|
||||
const $ = load(html)
|
||||
const installCommands = $('code')
|
||||
.map(function () {
|
||||
return $(this).text()
|
||||
})
|
||||
.get()
|
||||
.filter((text) => text.startsWith('npx skills add supabase/agent-skills --skill '))
|
||||
|
||||
expect(installCommands.length).toBeGreaterThan(0)
|
||||
// Test timeout must outlive the fetch abort so the network error surfaces
|
||||
// instead of a generic vitest timeout.
|
||||
}, 45_000)
|
||||
})
|
||||
+5
-3
@@ -11,15 +11,17 @@ type Params = { slug?: string[] }
|
||||
|
||||
const MonitoringTroubleshootingGuidePage = async (props: { params: Promise<Params> }) => {
|
||||
const params = await props.params
|
||||
const slug = ['telemetry', ...(params.slug ?? [])]
|
||||
const slug = ['monitoring-and-debugging', ...(params.slug ?? [])]
|
||||
const data = await getGuidesMarkdown(slug)
|
||||
|
||||
return <GuideTemplate {...data!} />
|
||||
}
|
||||
|
||||
const generateStaticParams = !IS_DEV ? genGuidesStaticParams('telemetry') : getEmptyArray
|
||||
const generateStaticParams = !IS_DEV
|
||||
? genGuidesStaticParams('monitoring-and-debugging')
|
||||
: getEmptyArray
|
||||
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
|
||||
getGuidesMarkdown(['telemetry', ...(params.slug ?? [])])
|
||||
getGuidesMarkdown(['monitoring-and-debugging', ...(params.slug ?? [])])
|
||||
)
|
||||
|
||||
export default MonitoringTroubleshootingGuidePage
|
||||
@@ -0,0 +1,5 @@
|
||||
import Layout from '~/layouts/guides'
|
||||
|
||||
export default async function MonitoringAndDebugging({ children }: { children: React.ReactNode }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import Layout from '~/layouts/guides'
|
||||
|
||||
export default async function Telemetry({ children }: { children: React.ReactNode }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import '@code-hike/mdx/styles.css'
|
||||
import 'config/code-hike.css'
|
||||
import 'ui-patterns/ShimmeringLoader/index.css'
|
||||
import '../styles/globals.css'
|
||||
import '../styles/prism-okaidia.css'
|
||||
|
||||
import { SkipToContent } from '~/components/SkipToContent'
|
||||
import { GlobalProviders } from '~/features/app.providers'
|
||||
import { DOCS_CONTENT_CONTAINER_ID } from '~/features/ui/helpers.constants'
|
||||
import { TopNavSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { BASE_PATH, IS_PRODUCTION } from '~/lib/constants'
|
||||
import { getCustomContent } from '~/lib/custom-content/getCustomContent'
|
||||
import { TelemetryTagManager } from 'common'
|
||||
import { genFaviconData } from 'common/MetaFavicons/app-router'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { SkipToContent } from 'ui-patterns/SkipToContent'
|
||||
|
||||
import { inter, manrope } from '@/fonts'
|
||||
|
||||
@@ -55,7 +54,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`} suppressHydrationWarning>
|
||||
<body>
|
||||
<SkipToContent />
|
||||
<SkipToContent href={`#${DOCS_CONTENT_CONTAINER_ID}`} />
|
||||
<TelemetryTagManager />
|
||||
<GlobalProviders>
|
||||
<TopNavSkeleton>{children}</TopNavSkeleton>
|
||||
|
||||
+62
-68
@@ -2,13 +2,12 @@ import { isFeatureEnabled } from 'common'
|
||||
import { type Metadata, type ResolvingMetadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { cn } from 'ui'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
import { TextLink } from 'ui-patterns/TextLink'
|
||||
|
||||
import { FrameworkQuickstarts } from '@/components/HomePageCover'
|
||||
import { FrameworkQuickstarts } from '@/components/FrameworkQuickstarts'
|
||||
import { MIGRATION_PAGES } from '@/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { GlassPanelWithIconPicker } from '@/features/ui/GlassPanelWithIconPicker'
|
||||
import { IconPanelWithIconPicker } from '@/features/ui/IconPanelWithIconPicker'
|
||||
import { IconLinkImage, IconLinkList, IconLinkMenuIcon } from '@/features/ui/IconLink'
|
||||
import HomeLayout from '@/layouts/HomeLayout'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
@@ -88,31 +87,26 @@ const postgresIntegrations = [
|
||||
title: 'AI & Vectors',
|
||||
icon: 'ai',
|
||||
href: '/guides/ai',
|
||||
description: 'AI toolkit to manage embeddings',
|
||||
},
|
||||
{
|
||||
title: 'Cron',
|
||||
icon: 'cron',
|
||||
href: '/guides/cron',
|
||||
description: 'Schedule and monitor recurring Jobs',
|
||||
},
|
||||
{
|
||||
title: 'Queues',
|
||||
icon: 'queues',
|
||||
href: '/guides/queues',
|
||||
description: 'Durable Message Queues with guaranteed delivery',
|
||||
},
|
||||
{
|
||||
title: 'Data REST API',
|
||||
icon: 'rest',
|
||||
href: '/guides/api',
|
||||
description: 'Access your database through a RESTful API.',
|
||||
},
|
||||
{
|
||||
title: 'GraphQL API',
|
||||
icon: 'graphql',
|
||||
href: '/guides/graphql',
|
||||
description: 'Access your database through a GraphQL API.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -224,6 +218,20 @@ const additionalResources = [
|
||||
},
|
||||
]
|
||||
|
||||
const migrationGuides = [...MIGRATION_PAGES]
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
||||
.flatMap((guide) => {
|
||||
if (!guide.name || !guide.url || typeof guide.icon !== 'string') return []
|
||||
|
||||
return [
|
||||
{
|
||||
title: guide.name,
|
||||
href: guide.url,
|
||||
icon: <IconLinkImage path={guide.icon} hasLightIcon={guide.hasLightIcon} />,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const HomePage = () => (
|
||||
<HomeLayout>
|
||||
<div className="flex flex-col">
|
||||
@@ -234,12 +242,12 @@ const HomePage = () => (
|
||||
Connect a framework
|
||||
</h2>
|
||||
<p className="m-0 p-0 text-sm text-foreground-light">
|
||||
Start with a framework quickstart and connect your project in minutes.
|
||||
Start with a quickstart guide to connect your project in minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8 not-prose">
|
||||
<FrameworkQuickstarts />
|
||||
<FrameworkQuickstarts labelledBy="connect-a-framework" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -253,7 +261,10 @@ const HomePage = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600">
|
||||
<ul
|
||||
aria-labelledby="products"
|
||||
className="col-span-8 grid grid-cols-12 gap-6 not-prose [&_svg]:text-brand-600"
|
||||
>
|
||||
{products.map((product) => {
|
||||
return (
|
||||
<li key={product.title} className={cn(product.span ?? 'col-span-12 md:col-span-6')}>
|
||||
@@ -277,18 +288,15 @@ const HomePage = () => (
|
||||
Extend your database with built-in tools for AI, APIs, scheduled jobs, and queues.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{postgresIntegrations.map((integration) => (
|
||||
<Link
|
||||
href={integration.href}
|
||||
key={integration.title}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker {...integration} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<IconLinkList
|
||||
labelledBy="postgres-integrations"
|
||||
className="col-span-8 not-prose"
|
||||
items={postgresIntegrations.map((integration) => ({
|
||||
title: integration.title,
|
||||
href: integration.href,
|
||||
icon: <IconLinkMenuIcon icon={integration.icon} />,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
@@ -301,23 +309,17 @@ const HomePage = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{clientLibraries
|
||||
<IconLinkList
|
||||
labelledBy="client-libraries"
|
||||
className="col-span-8 not-prose"
|
||||
items={clientLibraries
|
||||
.filter((library) => library.enabled)
|
||||
|
||||
.map((library) => {
|
||||
return (
|
||||
<Link
|
||||
href={library.href}
|
||||
key={library.title}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker {...library} />
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
.map((library) => ({
|
||||
title: library.title,
|
||||
href: library.href,
|
||||
icon: <IconLinkMenuIcon icon={library.icon} />,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{isFeatureEnabled('docs:full_getting_started') && (
|
||||
<div className="flex flex-col gap-6 border-b py-12 lg:grid lg:grid-cols-12 lg:gap-x-16">
|
||||
@@ -335,19 +337,11 @@ const HomePage = () => (
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{MIGRATION_PAGES.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(
|
||||
(guide) => {
|
||||
return (
|
||||
<li key={guide.name} className="col-span-6 md:col-span-4">
|
||||
<Link href={guide.url || '#'} passHref>
|
||||
<IconPanel {...guide} title={guide.name} background={true} showLink={false} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</ul>
|
||||
<IconLinkList
|
||||
labelledBy="migrate-to-supabase"
|
||||
className="col-span-8 not-prose"
|
||||
items={migrationGuides}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -361,7 +355,10 @@ const HomePage = () => (
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="col-span-8 grid grid-cols-12 gap-6 not-prose">
|
||||
<ul
|
||||
aria-labelledby="additional-resources"
|
||||
className="col-span-8 grid grid-cols-12 gap-6 not-prose"
|
||||
>
|
||||
{additionalResources.map((resource) => {
|
||||
return (
|
||||
<li key={resource.title} className="col-span-12 md:col-span-6">
|
||||
@@ -370,7 +367,7 @@ const HomePage = () => (
|
||||
passHref
|
||||
target={resource.external ? '_blank' : undefined}
|
||||
>
|
||||
<GlassPanelWithIconPicker {...resource} background={false}>
|
||||
<GlassPanelWithIconPicker {...resource}>
|
||||
{resource.description}
|
||||
</GlassPanelWithIconPicker>
|
||||
</Link>
|
||||
@@ -392,26 +389,23 @@ const HomePage = () => (
|
||||
Get started with self-hosting Supabase.
|
||||
</p>
|
||||
<TextLink
|
||||
label="More on Self-Hosting"
|
||||
label="More on self-hosting"
|
||||
url="/guides/self-hosting"
|
||||
className="no-underline text-brand-link text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
<ul className="col-span-full lg:col-span-8 grid grid-cols-12 gap-6">
|
||||
{selfHostingOptions.map((option) => {
|
||||
return (
|
||||
<li key={option.title} className="col-span-6">
|
||||
<Link href={option.href} passHref>
|
||||
<IconPanelWithIconPicker {...option} background={true} showLink={false} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<IconLinkList
|
||||
labelledBy="self-hosting"
|
||||
className="col-span-8 not-prose"
|
||||
itemClassName="col-span-6"
|
||||
items={selfHostingOptions.map((option) => ({
|
||||
title: option.title,
|
||||
href: option.href,
|
||||
icon: <IconLinkMenuIcon icon={option.icon} />,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { REFERENCES, clientSdkIds } from '~/content/navigation.references'
|
||||
import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker'
|
||||
import { clientSdkIds, REFERENCES } from '~/content/navigation.references'
|
||||
import { IconLinkList, IconLinkMenuIcon } from '~/features/ui/IconLink'
|
||||
import { LayoutMainContent } from '~/layouts/DefaultLayout'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
|
||||
@@ -20,41 +18,37 @@ export default function ReferenceIndexPage() {
|
||||
Supabase also has a Management API to help with managing your Supabase Platform, and a
|
||||
CLI for local development and CI workflows.
|
||||
</p>
|
||||
<h2 className="mb-8">Client Libraries</h2>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
{clientSdkIds.map((sdkId) => {
|
||||
return (
|
||||
<Link
|
||||
key={REFERENCES[sdkId].name}
|
||||
href={`/reference/${REFERENCES[sdkId].libPath}`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker
|
||||
title={REFERENCES[sdkId].name}
|
||||
icon={REFERENCES[sdkId].icon}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<h2 className="mb-8">Management API and CLI</h2>
|
||||
<div className="grid col-span-8 grid-cols-12 gap-6 not-prose">
|
||||
<Link
|
||||
href={`/reference/api/introduction`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker title="Management API" icon={REFERENCES['api'].icon} />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/reference/cli/introduction`}
|
||||
passHref
|
||||
className="col-span-6 md:col-span-4"
|
||||
>
|
||||
<IconPanelWithIconPicker title="CLI" icon={REFERENCES['cli'].icon} />
|
||||
</Link>
|
||||
</div>
|
||||
<h2 id="client-libraries" className="mb-8">
|
||||
Client Libraries
|
||||
</h2>
|
||||
<IconLinkList
|
||||
labelledBy="client-libraries"
|
||||
className="not-prose"
|
||||
items={clientSdkIds.map((sdkId) => ({
|
||||
title: REFERENCES[sdkId].name,
|
||||
href: `/reference/${REFERENCES[sdkId].libPath}`,
|
||||
icon: <IconLinkMenuIcon icon={REFERENCES[sdkId].icon} />,
|
||||
}))}
|
||||
/>
|
||||
<h2 id="management-api-and-cli" className="mb-8">
|
||||
Management API and CLI
|
||||
</h2>
|
||||
<IconLinkList
|
||||
labelledBy="management-api-and-cli"
|
||||
className="not-prose"
|
||||
items={[
|
||||
{
|
||||
title: 'Management API',
|
||||
href: '/reference/api/introduction',
|
||||
icon: <IconLinkMenuIcon icon={REFERENCES['api'].icon} />,
|
||||
},
|
||||
{
|
||||
title: 'CLI',
|
||||
href: '/reference/cli/introduction',
|
||||
icon: <IconLinkMenuIcon icon={REFERENCES['cli'].icon} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
</LayoutMainContent>
|
||||
</SidebarSkeleton>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Input } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { Input as DataInput } from 'ui-patterns/DataInputs/Input'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
import providers from '../data/authProviders'
|
||||
import Link from 'next/link'
|
||||
import { IconLinkImage, IconLinkList } from '@/features/ui/IconLink'
|
||||
|
||||
export default function AuthProviders({ type }: { type: string }) {
|
||||
const filterProviders = providers.filter((item) => item.authType === type)
|
||||
export default function AuthProviders({
|
||||
type,
|
||||
labelledBy,
|
||||
label,
|
||||
}: {
|
||||
type: string
|
||||
labelledBy?: string
|
||||
label?: string
|
||||
}) {
|
||||
const items = providers
|
||||
.filter((item) => item.authType === type)
|
||||
.map((provider) => ({
|
||||
title: provider.name,
|
||||
href: provider.href,
|
||||
icon: <IconLinkImage path={provider.logo} hasLightIcon={provider.hasLightIcon} />,
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-12 xs:gap-x-10 gap-y-10 not-prose py-8">
|
||||
{filterProviders.map((x) => (
|
||||
<Link
|
||||
href={`${x.href}`}
|
||||
key={x.name}
|
||||
passHref
|
||||
className="col-span-12 xs:col-span-6 lg:col-span-4 xl:col-span-3"
|
||||
>
|
||||
<IconPanel title={x.name} icon={x.logo} hasLightIcon={x.hasLightIcon} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<IconLinkList
|
||||
labelledBy={labelledBy}
|
||||
label={label}
|
||||
className="not-prose py-8"
|
||||
itemClassName="col-span-12 xs:col-span-6 lg:col-span-4 xl:col-span-3"
|
||||
items={items}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { safeHistoryReplaceState } from '~/lib/historyUtils'
|
||||
import { useEffect, useReducer, useRef } from 'react'
|
||||
import { useEffect, useId, useReducer, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogSection, Heading } from 'ui'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
|
||||
import { PhoneLoginsItems } from '../Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import MessageBird from './MessageBirdConfig.mdx'
|
||||
import TextLocal from './TextLocalConfig.mdx'
|
||||
import Twilio from './TwilioConfig.mdx'
|
||||
import Vonage from './VonageConfig.mdx'
|
||||
import { IconLinkButton, IconLinkImage } from '@/features/ui/IconLink'
|
||||
|
||||
const reducer = (_, action: (typeof PhoneLoginsItems)[number] | undefined) => {
|
||||
const url = new URL(document.location.href)
|
||||
@@ -24,6 +24,8 @@ const reducer = (_, action: (typeof PhoneLoginsItems)[number] | undefined) => {
|
||||
|
||||
const AuthSmsProviderConfig = () => {
|
||||
const [selectedProvider, setSelectedProvider] = useReducer(reducer, undefined)
|
||||
const dialogId = useId()
|
||||
const dialogTitleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const providerName = new URLSearchParams(document.location.search ?? '').get('showSmsProvider')
|
||||
@@ -41,22 +43,20 @@ const AuthSmsProviderConfig = () => {
|
||||
<h3 className="sr-only" id="sms-provider-configuration">
|
||||
Configuring SMS Providers
|
||||
</h3>
|
||||
<div className="grid grid-cols-6 gap-10 not-prose py-8">
|
||||
<ul className="grid grid-cols-12 gap-6 not-prose py-8">
|
||||
{PhoneLoginsItems.map((provider) => (
|
||||
<button
|
||||
tabIndex={0}
|
||||
key={provider.name}
|
||||
className="col-span-6 xl:col-span-3"
|
||||
onClick={() => setSelectedProvider(provider)}
|
||||
>
|
||||
<IconPanel
|
||||
<li key={provider.name} className="col-span-12 sm:col-span-6 xl:col-span-3">
|
||||
<IconLinkButton
|
||||
title={provider.name}
|
||||
icon={provider.icon}
|
||||
hasLightIcon={provider.hasLightIcon}
|
||||
icon={<IconLinkImage path={provider.icon} hasLightIcon={provider.hasLightIcon} />}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={selectedProvider?.name === provider.name}
|
||||
aria-controls={selectedProvider?.name === provider.name ? dialogId : undefined}
|
||||
onClick={() => setSelectedProvider(provider)}
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<Dialog
|
||||
open={!!selectedProvider}
|
||||
@@ -64,14 +64,16 @@ const AuthSmsProviderConfig = () => {
|
||||
>
|
||||
{selectedProvider && (
|
||||
<DialogContent
|
||||
id={dialogId}
|
||||
className="w-[min(90vw,80ch)]! max-w-[min(90vw,80ch)]! max-h-[90dvh]! prose overflow-auto"
|
||||
aria-labelledby={dialogTitleId}
|
||||
onOpenAutoFocus={(evt) => {
|
||||
evt.preventDefault()
|
||||
headingRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="pb-0 [&>h3]:m-0! [&>h3>a]:hidden! [&>h3:focus-visible]:outline-hidden">
|
||||
<Heading tag="h3" ref={headingRef} tabIndex={-1}>
|
||||
<Heading tag="h3" id={dialogTitleId} ref={headingRef} tabIndex={-1}>
|
||||
{selectedProvider.name}
|
||||
</Heading>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
<Admonition type="warning">
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ import { Heading } from 'ui/src/components/CustomHTMLElements'
|
||||
import { resolveContentListingIcon } from './iconChip'
|
||||
|
||||
const GRID_ITEM_CLASS = {
|
||||
// Stay 2-up until xl (~1280px) so cards aren't cramped beside the docs sidebar.
|
||||
2: 'col-span-12 md:col-span-6',
|
||||
3: 'col-span-12 md:col-span-4',
|
||||
4: 'col-span-12 md:col-span-3',
|
||||
3: 'col-span-12 md:col-span-6 xl:col-span-4',
|
||||
4: 'col-span-12 md:col-span-6 xl:col-span-3',
|
||||
} as const
|
||||
|
||||
function useContentListingClickHandler(group: ContentListingGroup) {
|
||||
@@ -81,8 +82,17 @@ function ContentListingsGroup({ group }: { group: ContentListingGroup }) {
|
||||
title={item.title}
|
||||
icon={resolveContentListingIcon(item.icon)}
|
||||
hasLightIcon={item.hasLightIcon ?? typeof item.icon === 'string'}
|
||||
badge={item.badge ? <Badge variant="success">{item.badge}</Badge> : undefined}
|
||||
badge={
|
||||
item.badge && item.badgePosition !== 'below' ? (
|
||||
<Badge variant="success">{item.badge}</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{item.badge && item.badgePosition === 'below' && (
|
||||
<Badge variant="success" className="mb-3 block w-fit">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
{item.description}
|
||||
</GlassPanel>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { TabPanel, Tabs } from '~/features/ui/Tabs'
|
||||
import { GENERATED_DIRECTORY } from '~/lib/docs'
|
||||
import { capitalize } from 'lodash-es'
|
||||
|
||||
interface Lint {
|
||||
path: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function DatabaseAdvisorsIndex() {
|
||||
let lints: Lint[] = []
|
||||
|
||||
try {
|
||||
const raw = await readFile(join(GENERATED_DIRECTORY, 'database-advisors.json'), 'utf-8')
|
||||
lints = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[database-advisors] Failed to read generated advisor docs; rendering without them',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs listClassNames="flex flex-wrap gap-2 [&>button]:m-0!" queryGroup="lint">
|
||||
{lints.map((lint) => (
|
||||
<TabPanel key={lint.path} id={lint.path} label={capitalize(lint.path.replace(/_/g, ' '))}>
|
||||
<section id={lint.path}>
|
||||
<MDXRemoteBase source={lint.content} />
|
||||
</section>
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -109,11 +109,12 @@ export default function Extensions() {
|
||||
)
|
||||
.map((extension) => (
|
||||
<Link
|
||||
key={extension.name}
|
||||
href={extension.link}
|
||||
target={getLinkTarget(extension.link)}
|
||||
className="no-underline"
|
||||
>
|
||||
<GlassPanel title={extension.name} background={false} key={extension.name}>
|
||||
<GlassPanel title={extension.name}>
|
||||
<p className="mt-4">
|
||||
{extension.comment.charAt(0).toUpperCase() + extension.comment.slice(1)}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { isFeatureEnabled } from 'common'
|
||||
|
||||
import { IconLinkImage, IconLinkList } from '@/features/ui/IconLink'
|
||||
|
||||
const {
|
||||
sdkDart: sdkDartEnabled,
|
||||
sdkKotlin: sdkKotlinEnabled,
|
||||
sdkSwift: sdkSwiftEnabled,
|
||||
} = isFeatureEnabled(['sdk:dart', 'sdk:kotlin', 'sdk:swift'])
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
name: 'React',
|
||||
icon: '/docs/img/icons/react-icon',
|
||||
href: '/guides/getting-started/quickstarts/reactjs',
|
||||
},
|
||||
{
|
||||
name: 'Next.js',
|
||||
icon: '/docs/img/icons/nextjs-icon',
|
||||
href: '/guides/getting-started/quickstarts/nextjs',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
name: 'TanStack Start',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
name: 'Astro',
|
||||
icon: '/docs/img/icons/astro-icon',
|
||||
href: '/guides/getting-started/quickstarts/astrojs',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: '/docs/img/icons/vuejs-icon',
|
||||
href: '/guides/getting-started/quickstarts/vue',
|
||||
},
|
||||
{
|
||||
name: 'Nuxt',
|
||||
icon: '/docs/img/icons/nuxt-icon',
|
||||
href: '/guides/getting-started/quickstarts/nuxtjs',
|
||||
},
|
||||
{
|
||||
name: 'iOS Swift',
|
||||
icon: '/docs/img/icons/swift-icon-orange',
|
||||
href: '/guides/getting-started/quickstarts/ios-swiftui',
|
||||
enabled: sdkSwiftEnabled,
|
||||
},
|
||||
{
|
||||
name: 'Android Kotlin',
|
||||
icon: '/docs/img/icons/kotlin-icon',
|
||||
href: '/guides/getting-started/quickstarts/kotlin',
|
||||
enabled: sdkKotlinEnabled,
|
||||
},
|
||||
{
|
||||
name: 'Expo React Native',
|
||||
icon: '/docs/img/icons/expo-icon',
|
||||
href: '/guides/getting-started/quickstarts/expo-react-native',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
name: 'Flutter',
|
||||
icon: '/docs/img/icons/flutter-icon',
|
||||
href: '/guides/getting-started/quickstarts/flutter',
|
||||
enabled: sdkDartEnabled,
|
||||
},
|
||||
{
|
||||
name: 'Python',
|
||||
icon: '/docs/img/icons/python-icon',
|
||||
href: '/guides/getting-started/quickstarts/flask',
|
||||
},
|
||||
]
|
||||
|
||||
export function FrameworkQuickstarts({ labelledBy }: { labelledBy?: string }) {
|
||||
return (
|
||||
<IconLinkList
|
||||
labelledBy={labelledBy}
|
||||
size="lg"
|
||||
items={frameworks
|
||||
.filter((framework) => framework.enabled !== false)
|
||||
.map((framework) => ({
|
||||
title: framework.name,
|
||||
href: framework.href,
|
||||
icon: (
|
||||
<IconLinkImage path={framework.icon} hasLightIcon={framework.hasLightIcon} size="lg" />
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { Feedback } from '~/components/Feedback'
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import { Chatgpt, Claude } from 'icons'
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react'
|
||||
import { Check, Copy, Sparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
@@ -25,6 +26,10 @@ function AiTools({ className }: { className?: string }) {
|
||||
const path = usePathname()
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
|
||||
function handleAgentSetupClick() {
|
||||
sendTelemetryEvent({ action: 'agent_setup_clicked' })
|
||||
}
|
||||
|
||||
async function copyMarkdown() {
|
||||
const mdUrl = `/docs/${path}.md`
|
||||
|
||||
@@ -52,18 +57,26 @@ function AiTools({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn(className)} aria-labelledby="ask-ai-title">
|
||||
<section className={cn(className)} aria-labelledby="ai-tools-title">
|
||||
<h3
|
||||
id="ask-ai-title"
|
||||
id="ai-tools-title"
|
||||
className="block font-mono uppercase text-xs text-foreground-light mb-3"
|
||||
>
|
||||
AI Tools
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/guides/ai-tools"
|
||||
onClick={handleAgentSetupClick}
|
||||
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={1.5} />
|
||||
Connect your AI agent
|
||||
</Link>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={copyMarkdown}
|
||||
className="flex items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground text-left transition-colors"
|
||||
className="flex cursor-pointer items-center gap-1.5 text-xs text-foreground-lighter hover:text-foreground text-left transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} strokeWidth={1.5} className="text-brand" />
|
||||
|
||||
@@ -7,28 +7,20 @@ import {
|
||||
PromptPanel,
|
||||
PromptTitle,
|
||||
} from '~/features/ui/PromptPanel'
|
||||
import { isFeatureEnabled, useBreakpoint } from 'common'
|
||||
import { isFeatureEnabled } from 'common'
|
||||
import { Sparkles, Terminal } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { IconPanel } from 'ui-patterns/IconPanel'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { getCustomContent } from '../lib/custom-content/getCustomContent'
|
||||
import DocsCoverLogo from './DocsCoverLogo'
|
||||
import { setupCommand, setupCommands, setupPrompt } from './HomePageCover.constants'
|
||||
|
||||
const {
|
||||
sdkDart: sdkDartEnabled,
|
||||
sdkKotlin: sdkKotlinEnabled,
|
||||
sdkSwift: sdkSwiftEnabled,
|
||||
} = isFeatureEnabled(['sdk:dart', 'sdk:kotlin', 'sdk:swift'])
|
||||
const fullGettingStartedEnabled = isFeatureEnabled('docs:full_getting_started')
|
||||
|
||||
function SetupPrompt({ cliCode }: { cliCode: ReactNode }) {
|
||||
return (
|
||||
<PromptPanel>
|
||||
<Prompt value="prompt" expandable>
|
||||
<Prompt value="prompt">
|
||||
<PromptTitle icon={<Sparkles />}>AI Prompt</PromptTitle>
|
||||
<PromptCopy>{setupPrompt}</PromptCopy>
|
||||
<PromptContent>
|
||||
@@ -58,134 +50,27 @@ function SetupPrompt({ cliCode }: { cliCode: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
tooltip: 'ReactJS',
|
||||
icon: '/docs/img/icons/react-icon',
|
||||
href: '/guides/getting-started/quickstarts/reactjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Next.js',
|
||||
icon: '/docs/img/icons/nextjs-icon',
|
||||
href: '/guides/getting-started/quickstarts/nextjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'TanStack Start',
|
||||
icon: '/docs/img/icons/tanstack-icon',
|
||||
href: '/guides/getting-started/quickstarts/tanstack',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Astro.js',
|
||||
icon: '/docs/img/icons/astro-icon',
|
||||
href: '/guides/getting-started/quickstarts/astrojs',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Vue',
|
||||
icon: '/docs/img/icons/vuejs-icon',
|
||||
href: '/guides/getting-started/quickstarts/vue',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Nuxt',
|
||||
icon: '/docs/img/icons/nuxt-icon',
|
||||
href: '/guides/getting-started/quickstarts/nuxtjs',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'iOS Swift',
|
||||
icon: '/docs/img/icons/swift-icon-orange',
|
||||
href: '/guides/getting-started/quickstarts/ios-swiftui',
|
||||
enabled: sdkSwiftEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Android Kotlin',
|
||||
icon: '/docs/img/icons/kotlin-icon',
|
||||
href: '/guides/getting-started/quickstarts/kotlin',
|
||||
enabled: sdkKotlinEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Expo React Native',
|
||||
icon: '/docs/img/icons/expo-icon',
|
||||
href: '/guides/getting-started/quickstarts/expo-react-native',
|
||||
hasLightIcon: true,
|
||||
},
|
||||
{
|
||||
tooltip: 'Flutter',
|
||||
icon: '/docs/img/icons/flutter-icon',
|
||||
href: '/guides/getting-started/quickstarts/flutter',
|
||||
enabled: sdkDartEnabled,
|
||||
hasLightIcon: false,
|
||||
},
|
||||
{
|
||||
tooltip: 'Python',
|
||||
icon: '/docs/img/icons/python-icon',
|
||||
href: '/guides/getting-started/quickstarts/flask',
|
||||
hasLightIcon: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function FrameworkQuickstarts() {
|
||||
const isXs = useBreakpoint(639)
|
||||
const iconSize = isXs ? 'sm' : 'lg'
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const isLightMode = isMounted && resolvedTheme === 'light'
|
||||
|
||||
useEffect(() => setIsMounted(true), [])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{frameworks
|
||||
.filter((framework) => framework.enabled !== false)
|
||||
.map((framework) => {
|
||||
const iconToUse =
|
||||
framework.hasLightIcon && isLightMode ? `${framework.icon}-light` : framework.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={framework.tooltip}
|
||||
href={framework.href}
|
||||
passHref
|
||||
className="col-span-6 no-underline md:col-span-4"
|
||||
>
|
||||
<IconPanel
|
||||
iconSize={iconSize}
|
||||
tooltip={framework.tooltip}
|
||||
title={framework.tooltip}
|
||||
icon={iconToUse}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HomePageCover = ({ title, cliCode }: { title: string; cliCode: ReactNode }) => {
|
||||
const { homepageHeading } = getCustomContent(['homepage:heading'])
|
||||
|
||||
return (
|
||||
<div className="w-full border-b bg-muted/10">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||
<div className="flex flex-col items-start gap-10 xl:flex-row xl:items-center xl:gap-16">
|
||||
<div className="flex w-full flex-1 flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
|
||||
<DocsCoverLogo aria-hidden="true" className="w-[60px] shrink-0 md:w-[100px]" />
|
||||
<div className="flex flex-col gap-10 lg:flex-row lg:items-center lg:gap-12 xl:gap-16">
|
||||
<div className="flex w-full min-w-0 flex-1 items-center gap-4 sm:gap-8">
|
||||
<DocsCoverLogo aria-hidden="true" className="w-12 shrink-0 sm:w-[60px] md:w-[100px]" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h1 className="m-0 text-4xl text-foreground">{homepageHeading || title}</h1>
|
||||
<p className="m-0 mt-3 text-xl leading-7 text-foreground-light">
|
||||
<h1 className="m-0 text-3xl text-foreground sm:text-4xl">
|
||||
{homepageHeading || title}
|
||||
</h1>
|
||||
<p className="m-0 mt-2 text-base leading-7 text-foreground-light sm:mt-3 sm:text-xl">
|
||||
Learn how to get up and running with Supabase through tutorials, APIs and platform
|
||||
resources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{fullGettingStartedEnabled && (
|
||||
<div className="w-full max-w-xl xl:max-w-[478px] xl:shrink-0">
|
||||
<div className="w-full lg:max-w-[478px] lg:shrink-0">
|
||||
<SetupPrompt cliCode={cliCode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,22 +41,33 @@ export interface ImageProps extends Omit<NextImageProps, 'src'> {
|
||||
* making sure it doesn't affect other projects consuming the component.
|
||||
*
|
||||
*/
|
||||
const Image = ({ src, alt = '', ...props }: ImageProps) => {
|
||||
const Image = ({
|
||||
src,
|
||||
alt = '',
|
||||
className,
|
||||
style,
|
||||
containerClassName,
|
||||
caption,
|
||||
// MDX can pass whitespace-only children (e.g. a blank line before `/>`);
|
||||
// never forward those to next/image.
|
||||
children: _children,
|
||||
...rest
|
||||
}: ImageProps) => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const source =
|
||||
typeof src === 'string' ? src : resolvedTheme?.includes('dark') ? src.dark : src.light
|
||||
|
||||
return (
|
||||
<figure className={props.containerClassName}>
|
||||
<figure className={containerClassName}>
|
||||
<NextImage
|
||||
key={resolvedTheme}
|
||||
alt={alt}
|
||||
src={source}
|
||||
className={props.className}
|
||||
style={props.style}
|
||||
{...props}
|
||||
className={className}
|
||||
style={style}
|
||||
{...rest}
|
||||
/>
|
||||
{props.caption && <figcaption className="text-center">{props.caption}</figcaption>}
|
||||
{caption && <figcaption className="text-center">{caption}</figcaption>}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,20 +13,17 @@ export const metricsStackOptions: MetricsStackOption[] = [
|
||||
title: 'Grafana Cloud (SaaS)',
|
||||
description:
|
||||
'Use Grafana Cloud’s managed Prometheus (works on Free + Pro tiers) and import the Supabase dashboard without running any infrastructure.',
|
||||
href: '/guides/telemetry/metrics/grafana-cloud',
|
||||
href: '/guides/monitoring-and-debugging/metrics/grafana-cloud',
|
||||
iconKind: 'grafana',
|
||||
iconColor: '#F05A28',
|
||||
iconBg: 'rgba(240,90,40,0.1)',
|
||||
badges: [
|
||||
{ label: 'Supabase guide', variant: 'default' },
|
||||
{ label: 'Community', variant: 'community' },
|
||||
],
|
||||
badges: [{ label: 'Supabase guide', variant: 'default' }],
|
||||
},
|
||||
{
|
||||
title: 'Grafana + self-hosted Prometheus',
|
||||
description:
|
||||
'Run Prometheus yourself following the official installation guidance and pair it with Grafana plus our dashboard JSON and alert pack.',
|
||||
href: '/guides/telemetry/metrics/grafana-self-hosted',
|
||||
href: '/guides/monitoring-and-debugging/metrics/grafana-self-hosted',
|
||||
iconKind: 'grafana',
|
||||
iconColor: '#F05A28',
|
||||
iconBg: 'rgba(240,90,40,0.1)',
|
||||
@@ -46,7 +43,7 @@ export const metricsStackOptions: MetricsStackOption[] = [
|
||||
title: 'Vendor-agnostic / BYO Prometheus',
|
||||
description:
|
||||
'Connect AWS AMP, Grafana Mimir, VictoriaMetrics, or any Prometheus-compatible SaaS with the same scrape job pattern.',
|
||||
href: '/guides/telemetry/metrics/vendor-agnostic',
|
||||
href: '/guides/monitoring-and-debugging/metrics/vendor-agnostic',
|
||||
iconKind: 'flame',
|
||||
iconColor: '#0BA678',
|
||||
iconBg: 'rgba(11,166,120,0.1)',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// End of third-party imports
|
||||
|
||||
import { isFeatureEnabled } from 'common/enabled-features'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { IconPanel } from 'ui-patterns/IconPanel'
|
||||
|
||||
import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Navigation.types'
|
||||
|
||||
@@ -212,9 +210,9 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
|
||||
level: 'security',
|
||||
},
|
||||
{
|
||||
label: 'Telemetry',
|
||||
label: 'Monitoring and Debugging',
|
||||
icon: 'telemetry',
|
||||
href: '/guides/telemetry' as `/${string}`,
|
||||
href: '/guides/monitoring-and-debugging' as `/${string}`,
|
||||
level: 'telemetry',
|
||||
},
|
||||
{
|
||||
@@ -2527,7 +2525,9 @@ export const contributing: NavMenuConstant = {
|
||||
items: [{ name: 'Overview', url: '/contributing' }],
|
||||
}
|
||||
|
||||
export const MIGRATION_PAGES: Partial<NavMenuSection & ComponentProps<typeof IconPanel>>[] = [
|
||||
export const MIGRATION_PAGES: Partial<
|
||||
NavMenuSection & { icon?: string; hasLightIcon?: boolean }
|
||||
>[] = [
|
||||
{
|
||||
name: 'Auth0',
|
||||
icon: '/docs/img/icons/auth0-icon',
|
||||
@@ -2983,57 +2983,59 @@ export const platform: NavMenuConstant = {
|
||||
|
||||
export const telemetry: NavMenuConstant = {
|
||||
icon: 'telemetry',
|
||||
title: 'Telemetry',
|
||||
url: '/guides/telemetry',
|
||||
title: 'Monitoring and Debugging',
|
||||
url: '/guides/monitoring-and-debugging',
|
||||
items: [
|
||||
{ name: 'Overview', url: '/guides/telemetry' },
|
||||
{ name: 'Overview', url: '/guides/monitoring-and-debugging' },
|
||||
{
|
||||
name: 'Logging & observability',
|
||||
name: 'Debugging',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Logging',
|
||||
url: '/guides/telemetry/logs' as `/${string}`,
|
||||
name: 'Debugging guide',
|
||||
url: '/guides/monitoring-and-debugging/debugging' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Debugging',
|
||||
url: '/guides/telemetry/debugging' as `/${string}`,
|
||||
name: 'Logging',
|
||||
url: '/guides/monitoring-and-debugging/logs' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Advanced log filtering',
|
||||
url: '/guides/telemetry/advanced-log-filtering' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/advanced-log-filtering' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Logs field reference',
|
||||
url: '/guides/telemetry/log-field-reference' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/log-field-reference' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Monitoring',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Log drains',
|
||||
url: '/guides/telemetry/log-drains' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Tracing with the JS SDK',
|
||||
url: '/guides/telemetry/client-side-tracing' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/log-drains' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
url: '/guides/telemetry/reports' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/reports' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Metrics',
|
||||
url: '/guides/telemetry/metrics' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/metrics' as `/${string}`,
|
||||
items: [
|
||||
{
|
||||
name: 'Overview',
|
||||
url: '/guides/telemetry/metrics' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/metrics' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Grafana Cloud',
|
||||
url: '/guides/telemetry/metrics/grafana-cloud' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/metrics/grafana-cloud' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Grafana self-hosted',
|
||||
url: '/guides/telemetry/metrics/grafana-self-hosted' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/metrics/grafana-self-hosted' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Datadog',
|
||||
@@ -3041,13 +3043,17 @@ export const telemetry: NavMenuConstant = {
|
||||
},
|
||||
{
|
||||
name: 'Vendor-agnostic setup',
|
||||
url: '/guides/telemetry/metrics/vendor-agnostic' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/metrics/vendor-agnostic' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Sentry integration',
|
||||
url: '/guides/telemetry/sentry-monitoring' as `/${string}`,
|
||||
url: '/guides/monitoring-and-debugging/sentry-monitoring' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Tracing with the JS SDK',
|
||||
url: '/guides/monitoring-and-debugging/client-side-tracing' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3091,6 +3097,10 @@ export const self_hosting: NavMenuConstant = {
|
||||
{ name: 'Configure SAML 2.0 SSO', url: '/guides/self-hosting/self-hosted-saml-sso' },
|
||||
{ name: 'Enable MCP server', url: '/guides/self-hosting/enable-mcp' },
|
||||
{ name: 'Remove superuser access', url: '/guides/self-hosting/remove-superuser-access' },
|
||||
{
|
||||
name: 'Custom Postgres Extensions',
|
||||
url: '/guides/self-hosting/custom-postgres-extensions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
|
||||
import type { ICommonItem } from '~/components/reference/Reference.types'
|
||||
import type { Json } from '~/features/helpers.types'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { menuState } from '../../../hooks/useMenuState'
|
||||
|
||||
export function getPathWithoutHash(relativePath: string) {
|
||||
@@ -134,7 +135,7 @@ export const getMenuId = (pathname: string | null) => {
|
||||
return MenuId.LocalDevelopment
|
||||
case pathname.startsWith('ai-tools'):
|
||||
return MenuId.AiTools
|
||||
case pathname.startsWith('telemetry'):
|
||||
case pathname.startsWith('monitoring-and-debugging'):
|
||||
return MenuId.Telemetry
|
||||
case pathname.startsWith('platform'):
|
||||
return MenuId.Platform
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { DOCS_CONTENT_CONTAINER_ID } from '~/features/ui/helpers.constants'
|
||||
import Link from 'next/link'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
const SkipToContent = () => {
|
||||
return (
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="default"
|
||||
asChild
|
||||
className={cn(
|
||||
'fixed top-0 left-4 z-[100] w-auto',
|
||||
'-translate-y-full focus-visible:translate-y-4',
|
||||
'transition-transform duration-200 ease-out'
|
||||
)}
|
||||
>
|
||||
<Link href={`#${DOCS_CONTENT_CONTAINER_ID}`}>Skip to content</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { SkipToContent }
|
||||
@@ -0,0 +1,20 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
export function WrapperDashboardIntegration({ title, path }: { title: string; path: string }) {
|
||||
return (
|
||||
<Admonition type="note" className="mb-4">
|
||||
<p>You can enable the {title} wrapper right from the Supabase dashboard.</p>
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`https://supabase.com/dashboard/project/_/integrations/${path}/overview`}
|
||||
className="no-underline"
|
||||
>
|
||||
Open wrapper in dashboard
|
||||
</Link>
|
||||
</Button>
|
||||
</Admonition>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 1186 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user