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:
Danny White committed 2026-07-31 14:46:10 +10:00
commit 564c64fd2c
1186 files changed
+28658 -12445

No files matched your search

+30 -24
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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 -1
View File
@@ -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
+12 -1
View File
@@ -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
+278
View File
@@ -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` |
+15 -1
View File
@@ -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.
+4 -4
View File
@@ -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
+2 -1
View File
@@ -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.
---
+14 -14
View File
@@ -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) |
+11 -3
View File
@@ -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
View File
@@ -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'
+88 -24
View File
@@ -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
+9 -9
View File
@@ -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"
+54
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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"
+22
View File
@@ -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",
+8 -3
View File
@@ -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.
-53
View File
@@ -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 -3
View File
@@ -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'
+5
View File
@@ -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
+23
View File
@@ -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'],
})
+2 -2
View File
@@ -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>
)
}
+10
View File
@@ -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',
+59 -3
View File
@@ -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;
}
+7 -2
View File
@@ -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
+1 -2
View File
@@ -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">
+1 -1
View File
@@ -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)
+3 -16
View File
@@ -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)
})
@@ -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>
}
+3 -4
View File
@@ -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
View File
@@ -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>
+33 -39
View File
@@ -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'
+24 -18
View File
@@ -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" />
),
}))}
/>
)
}
+17 -4
View File
@@ -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" />
+11 -126
View File
@@ -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>
)}
+17 -6
View File
@@ -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
-22
View File
@@ -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