From 3945234b9e1a303066c6c128ac7ebfea8c857745 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:17:36 +1000 Subject: [PATCH 1/2] feat(studio): polish replication destination sheet form (#49581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature (sheet polish, no wizard). ## What is the current behavior? Edit destination sheet uses a disabled region Select, weaker BigQuery JSON validation messaging, and a name field that password managers may fill. ## What is the new behavior? - Read-only pipeline region field with flag, display name, region code, and destination-specific hint - Clearer BigQuery service-account JSON validation - Destination name ignores password managers (`data-1p-ignore` and related attrs) - Advanced settings can optionally group fields (defaults keep full accordion for the sheet) Independent of the [create-pipeline wizard](https://github.com/supabase/supabase/pull/49243). Safe to merge on its own. | Before | After | | --- | --- | | | | | | | ## To test 1. Open a project → Database → Replication 2. Edit an existing destination 3. Confirm **Pipeline region** is read-only (not a combobox) and shows flag + region name/code 4. Open Advanced settings and confirm fields still appear 5. For BigQuery: paste invalid service-account JSON and confirm a clear validation message ## Summary by CodeRabbit - **New Features** - Added grouped advanced settings for connection and data configuration. - Added a read-only pipeline region display with destination-specific guidance. - Destination names now receive focus automatically when forms open. - **Bug Fixes** - Improved BigQuery credential validation, including malformed or missing service-account keys. - Password-manager autofill is now suppressed for destination name fields. - **Tests** - Added coverage for BigQuery validation and pipeline-region display behavior. --- .../BigQuery/BigQuery.utils.ts | 49 ++++++++-- .../DestinationForm.utils.test.ts | 52 +++++++++++ .../DestinationForm/DestinationNameInput.tsx | 10 ++- .../PipelineRegionField.test.tsx | 36 ++++++++ .../DestinationForm/PipelineRegionField.tsx | 59 ++++++++++++ .../DestinationForm/index.test.tsx | 1 + .../DestinationForm/index.tsx | 89 +++++-------------- 7 files changed, 221 insertions(+), 75 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts index 7b9e1c25a366d..5bc5181d1558a 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/BigQuery.utils.ts @@ -7,18 +7,51 @@ export type BigQueryValidationIssue = { message: string } -const BIGQUERY_REQUIRED_FIELDS: { path: BigQueryFieldPath; message: string }[] = [ +export const BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE = 'Service account key must be valid JSON' + +const BIGQUERY_REQUIRED_FIELDS: { + path: Exclude + message: string +}[] = [ { path: 'projectId', message: 'Project ID is required' }, { path: 'datasetId', message: 'Dataset ID is required' }, - { path: 'serviceAccountKey', message: 'Service account key is required' }, ] +const isValidJsonString = (value: string) => { + try { + JSON.parse(value) + return true + } catch { + return false + } +} + export const getBigQueryValidationIssues = ( data: Pick, - options: { secretsOptional?: boolean } = {} -): BigQueryValidationIssue[] => - BIGQUERY_REQUIRED_FIELDS.filter(({ path }) => { - if (options.secretsOptional && path === 'serviceAccountKey') return false + options: { secretsOptional?: boolean; validateJson?: boolean } = {} +): BigQueryValidationIssue[] => { + const { secretsOptional = false, validateJson = true } = options + const issues: BigQueryValidationIssue[] = BIGQUERY_REQUIRED_FIELDS.filter( + ({ path }) => !data[path]?.trim().length + ).map(({ path, message }) => ({ path, message })) - return !data[path]?.trim().length - }) + const serviceAccountKey = data.serviceAccountKey?.trim() ?? '' + + if (!serviceAccountKey) { + if (!secretsOptional) { + issues.push({ path: 'serviceAccountKey', message: 'Service account key is required' }) + } + return issues + } + + // JSON shape is checked on submit only. Live onChange validation would fail on every + // keystroke while the user is still pasting or typing a key. + if (validateJson && !isValidJsonString(serviceAccountKey)) { + issues.push({ + path: 'serviceAccountKey', + message: BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE, + }) + } + + return issues +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts index 3c55a6a9de0fe..05470fe0dea77 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationForm.utils.test.ts @@ -769,6 +769,58 @@ describe('DestinationForm.utils BigQuery', () => { expect(issues).toEqual([]) }) + it('rejects invalid JSON in the service account key', () => { + const issues = getBigQueryValidationIssues({ + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ "type": "service_account" ', + }) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + + it('skips JSON shape checks when validateJson is false', () => { + const issues = getBigQueryValidationIssues( + { + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ "type": "service_account" ', + }, + { validateJson: false } + ) + + expect(issues).toEqual([]) + }) + + it('rejects non-JSON text in the service account key', () => { + const issues = getBigQueryValidationIssues({ + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: 'not-json', + }) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + + it('validates JSON when replacing credentials in edit mode', () => { + const issues = getBigQueryValidationIssues( + { + projectId: 'my-project', + datasetId: 'my_dataset', + serviceAccountKey: '{ invalid', + }, + { secretsOptional: true } + ) + + expect(issues).toEqual([ + { path: 'serviceAccountKey', message: 'Service account key must be valid JSON' }, + ]) + }) + it('allows an omitted BigQuery service account key in edit mode', () => { const issues = getBigQueryValidationIssues( { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx index c04a4c1a2dce9..a95827fa29d23 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DestinationNameInput.tsx @@ -16,7 +16,15 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => { render={({ field }) => ( - + )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx new file mode 100644 index 0000000000000..0afb0d8347395 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.test.tsx @@ -0,0 +1,36 @@ +import { screen } from '@testing-library/react' +import { describe, expect, test } from 'vitest' + +import { + getPipelineRegionDescription, + PIPELINE_REGION, + PipelineRegionField, +} from './PipelineRegionField' +import { customRender } from '@/tests/lib/custom-render' + +describe('getPipelineRegionDescription', () => { + test('includes the fixed-region note and destination-specific hint', () => { + expect(getPipelineRegionDescription('BigQuery')).toBe( + 'All pipelines run from this region. Choose a nearby BigQuery dataset where possible.' + ) + }) + + test('falls back to a generic destination hint', () => { + expect(getPipelineRegionDescription()).toBe( + 'All pipelines run from this region. Choose a nearby destination region where possible.' + ) + }) +}) + +describe('PipelineRegionField', () => { + test('shows the managed region as read-only information, not a combobox', () => { + customRender() + + expect(screen.getByText('Pipeline region')).toBeInTheDocument() + expect(screen.getByText(PIPELINE_REGION.displayName)).toBeInTheDocument() + expect(screen.getByText(PIPELINE_REGION.code)).toBeInTheDocument() + expect(screen.getByText(/All pipelines run from this region/)).toBeInTheDocument() + expect(screen.getByText(/nearby BigQuery dataset/)).toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx new file mode 100644 index 0000000000000..9cd0941cbf90c --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/PipelineRegionField.tsx @@ -0,0 +1,59 @@ +import { AWS_REGIONS } from 'shared-data' +import { cn } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import type { DestinationType } from '../DestinationPanel.types' +import { RegionFlag } from '@/components/ui/RegionFlag' +import { IS_STAGING_OR_LOCAL } from '@/lib/constants' + +// Pipelines always run from a single fixed region per environment, regardless of the source +// project's region. +export const PIPELINE_REGION = IS_STAGING_OR_LOCAL + ? AWS_REGIONS.SOUTHEAST_ASIA + : AWS_REGIONS.CENTRAL_EU + +const DESTINATION_REGION_HINT: Record = { + BigQuery: 'Choose a nearby BigQuery dataset where possible.', + 'Analytics Bucket': 'Keep this bucket in the same region where possible.', + DuckLake: 'Keep catalog and object storage close to this region where possible.', + Snowflake: 'Choose a nearby Snowflake account region where possible.', + ClickHouse: 'Choose a nearby ClickHouse cluster where possible.', +} + +export const getPipelineRegionDescription = (destinationType?: DestinationType) => { + const destinationHint = destinationType + ? DESTINATION_REGION_HINT[destinationType] + : 'Choose a nearby destination region where possible.' + + return `All pipelines run from this region. ${destinationHint}` +} + +export const PipelineRegionField = ({ + destinationType, + className, +}: { + destinationType?: DestinationType + className?: string +}) => { + return ( + + + + {PIPELINE_REGION.displayName} + + {PIPELINE_REGION.code} + + + + ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx index 675c129e84b45..d6313a5772d74 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.test.tsx @@ -71,6 +71,7 @@ vi.mock('ui', () => ({ {children} ), + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), DialogSectionSeparator: () => null, Form: ({ children }: PropsWithChildren) => children, Select: ({ children }: PropsWithChildren) => {children}, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx index 61670a6b66cdb..b4f8f25a13f87 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationForm/index.tsx @@ -5,25 +5,9 @@ import { AnimatePresence, motion } from 'framer-motion' import { Loader2 } from 'lucide-react' import { RefObject, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useForm, useWatch } from 'react-hook-form' -import { AWS_REGIONS } from 'shared-data' import { toast } from 'sonner' -import { - Button, - DialogSectionSeparator, - Form, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SheetFooter, - SheetSection, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Button, DialogSectionSeparator, Form, SheetFooter, SheetSection } from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' import { @@ -37,7 +21,10 @@ import { type DestinationType, type ExistingDestination } from '../DestinationPa import { AdvancedSettings } from './AdvancedSettings' import { getAnalyticsBucketValidationIssues } from './AnalyticsBucket/AnalyticsBucket.utils' import { AnalyticsBucketFields } from './AnalyticsBucket/Fields' -import { getBigQueryValidationIssues } from './BigQuery/BigQuery.utils' +import { + BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE, + getBigQueryValidationIssues, +} from './BigQuery/BigQuery.utils' import { BigQueryFields } from './BigQuery/Fields' import { getClickHouseValidationIssues } from './ClickHouse/ClickHouse.utils' import { ClickHouseFields } from './ClickHouse/Fields' @@ -54,6 +41,7 @@ import { DuckLakeFields } from './DuckLake/Fields' import { NewPublicationPanel } from './NewPublicationPanel' import { NoDestinationsAvailable } from './NoDestinationsAvailable' import { PipelineCostDialog } from './PipelineCostDialog' +import { PipelineRegionField } from './PipelineRegionField' import { PublicationSelection } from './PublicationSelection' import { SnowflakeFields } from './Snowflake/Fields' import { getSnowflakeValidationIssues } from './Snowflake/Snowflake.utils' @@ -62,8 +50,6 @@ import { useDestinationForm } from './useDestinationForm' import { ValidationFailuresSection } from './ValidationFailuresSection' import { ValidationWarningsDialog } from './ValidationWarningsDialog' import { CreateAnalyticsBucketSheet } from '@/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketSheet' -import { InlineLinkClassName } from '@/components/ui/InlineLink' -import { RegionFlag } from '@/components/ui/RegionFlag' import { useAPIKeys } from '@/data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query' @@ -71,14 +57,9 @@ import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by- import { useReplicationPublicationsQuery } from '@/data/replication/publications-query' import { useReplicationSourceId } from '@/data/replication/sources-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { IS_STAGING_OR_LOCAL } from '@/lib/constants' const formId = 'destination-editor' -// Pipelines always run out of a single fixed region per environment, regardless of the source -// project's region. -const PIPELINE_REGION = IS_STAGING_OR_LOCAL ? AWS_REGIONS.SOUTHEAST_ASIA : AWS_REGIONS.CENTRAL_EU - interface DestinationFormProps { selectedType: DestinationType visible: boolean @@ -232,11 +213,12 @@ export const DestinationForm = ({ } if (selectedType === 'BigQuery') { - getBigQueryValidationIssues(data, { secretsOptional: editMode }).forEach( - ({ path, message }) => { - addRequiredFieldError(path, message) - } - ) + getBigQueryValidationIssues(data, { + secretsOptional: editMode, + validateJson: false, + }).forEach(({ path, message }) => { + addRequiredFieldError(path, message) + }) } else if (selectedType === 'Analytics Bucket') { getAnalyticsBucketValidationIssues(data, { secretsOptional: editMode, @@ -342,6 +324,16 @@ export const DestinationForm = ({ }), } + if (selectedType === 'BigQuery') { + const jsonIssue = getBigQueryValidationIssues(data, { secretsOptional: editMode }).find( + (issue) => issue.message === BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE + ) + if (jsonIssue) { + form.setError(jsonIssue.path, { message: jsonIssue.message }) + return + } + } + // Pipeline prerequisite validation models a new pipeline and cannot // account for resources already owned by an existing pipeline. Edits keep // the established direct-update flow after pruning stale table ids. @@ -469,42 +461,7 @@ export const DestinationForm = ({ onSelectNewPublication={() => setPublicationPanelVisible(true)} /> - - Pipelines run in{' '} - - - {PIPELINE_REGION.displayName} - - {PIPELINE_REGION.code} - - . In your destination provider, choose the closest available region. - - } - > - - - - - - - - - - {PIPELINE_REGION.displayName} - - {PIPELINE_REGION.code} - - - - - - - + From 164de2c347c31e281fab3aad1dc31fd898504072 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Thu, 27 Aug 2026 11:29:04 +0800 Subject: [PATCH 2/2] feat(www): markdown 404 for markdown-negotiated paths (#49596) Nonexistent paths return a real 404 everywhere, but always with an HTML body, even when the client asked for markdown via `Accept: text/markdown` or a `.md` suffix. Middleware can't fix this: it gates on a static slug allowlist and can't know a path will 404. I added two `fallback` rewrites (`.md` suffix; Accept header containing `text/markdown` or `text/*`) that run only after every route has failed to match and route the request to a small `md-404` handler returning a short markdown 404 pointing at /docs, /sitemap.xml, and /llms.txt. Real pages are structurally unaffected. **Note:** `lib/rewrites.js` is untouched (the plain rewrites array became the `afterFiles` phase), so #49587 merges independently. I updated next.config.test.ts's rewrites assertion for the phased shape; it now also pins the two fallback rules. ## To test I verified on the Vercel preview: - [x] `curl -s -D - -H "Accept: text/markdown" /definitely-not-a-page` (404, `Content-Type: text/markdown`, body with the three pointers) - [x] Same URL with a browser Accept header (existing HTML 404, unchanged) - [x] `curl -s -D - /definitely-not-a-page.md` (markdown 404) - [x] `curl -s -D - -H "Accept: text/markdown" /auth` (200 markdown, unchanged) and `/support` (200 HTML, unchanged) - [x] `/homepage.md` still 308s to `/index.md` (redirects phase wins); `Accept: text/*` gets the markdown 404, matching real-page negotiation Known boundary: `/changelog/` keeps the HTML 404 body (pages-router `fallback: 'blocking'` routes take priority over fallback rewrites per Next docs); the status is still 404, verified on the preview. ## Linear - fixes GROWTH-1142 ## Summary by CodeRabbit - **New Features** - Added Markdown-formatted 404 responses for unmatched documentation and `.md` page requests. - Included helpful documentation links in not-found responses. - Requests that explicitly accept Markdown now receive a consistent Markdown response. - Added appropriate response headers for security, caching, and content variation. - **Bug Fixes** - Improved routing for unmatched Markdown paths, ensuring they are handled by the appropriate not-found response instead of returning an unexpected format. --- .../app/api-v2/md-404/[[...path]]/route.ts | 30 +++++++++++++++++ apps/www/next.config.mjs | 17 ++++++++-- apps/www/next.config.test.ts | 32 +++++++++++++++++-- 3 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 apps/www/app/api-v2/md-404/[[...path]]/route.ts diff --git a/apps/www/app/api-v2/md-404/[[...path]]/route.ts b/apps/www/app/api-v2/md-404/[[...path]]/route.ts new file mode 100644 index 0000000000000..4aa71ab7dd5dd --- /dev/null +++ b/apps/www/app/api-v2/md-404/[[...path]]/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' + +const MARKDOWN_404_HEADERS = { + 'Content-Type': 'text/markdown; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + Vary: 'Accept', +} + +function buildMarkdown404Body(requestPath: string): string { + const safePath = requestPath.replace(/[^\x20-\x7E]/g, '').replaceAll('`', '') + return `# 404 Not Found + +\`${safePath}\` does not exist on supabase.com. + +Explore instead: + +- [Documentation](https://supabase.com/docs) +- [Sitemap](https://supabase.com/sitemap.xml) +- [llms.txt](https://supabase.com/llms.txt) +` +} + +export async function GET(_request: Request, { params }: { params: Promise<{ path?: string[] }> }) { + const { path } = await params + return new NextResponse(buildMarkdown404Body('/' + (path ?? []).join('/')), { + status: 404, + headers: MARKDOWN_404_HEADERS, + }) +} diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index 9286c3c07afc0..0f4d2489277d1 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -80,7 +80,7 @@ const nextConfig = { */ outputFileTracingIncludes: { '/llms.txt': ['../docs/content/guides/*'], - '/llms-full.txt': ['../docs/public/markdown/guides/**/*.md' ], + '/llms-full.txt': ['../docs/public/markdown/guides/**/*.md'], }, reactStrictMode: true, images: { @@ -194,7 +194,20 @@ const nextConfig = { ] }, async rewrites() { - return rewrites + return { + afterFiles: rewrites, + fallback: [ + { + source: '/:path(.*\\.md)', + destination: '/api-v2/md-404/:path', + }, + { + source: '/:path*', + has: [{ type: 'header', key: 'accept', value: '.*text/(markdown|\\*).*' }], + destination: '/api-v2/md-404/:path*', + }, + ], + } }, async redirects() { // For /docs/guides/ redirects, auto-generate .md variants so renamed/deleted pages diff --git a/apps/www/next.config.test.ts b/apps/www/next.config.test.ts index e88f7141eced4..083c7a260d55b 100644 --- a/apps/www/next.config.test.ts +++ b/apps/www/next.config.test.ts @@ -27,10 +27,11 @@ describe('next.config.mjs', () => { it('routes the library and permanently redirects the legacy UI URLs', async () => { const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } - const rewrites = (await config.rewrites?.()) || [] + const rewrites = await config.rewrites?.() + const afterFiles = (rewrites && 'afterFiles' in rewrites && rewrites.afterFiles) || [] const redirects = (await config.redirects?.()) || [] - expect(rewrites).toEqual( + expect(afterFiles).toEqual( expect.arrayContaining([ expect.objectContaining({ source: '/library' }), expect.objectContaining({ source: '/library/:path*' }), @@ -61,6 +62,33 @@ describe('next.config.mjs', () => { ).toBeLessThan(redirects.findIndex((redirect) => redirect.source === '/ui/:path*')) }) + it('routes unmatched markdown-negotiated paths to the md-404 handler via fallback rewrites', async () => { + const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } + const rewrites = await config.rewrites?.() + const fallback = (rewrites && 'fallback' in rewrites && rewrites.fallback) || [] + + const mdSuffixRule = fallback.find((rule) => rule.destination === '/api-v2/md-404/:path') + const acceptRule = fallback.find((rule) => rule.destination === '/api-v2/md-404/:path*') + + expect(mdSuffixRule).toBeDefined() + expect(acceptRule?.has).toEqual([ + { type: 'header', key: 'accept', value: '.*text/(markdown|\\*).*' }, + ]) + + expect(getPathMatch(mdSuffixRule!.source)('/definitely-not-a-page.md')).toBeTruthy() + expect(getPathMatch(mdSuffixRule!.source)('/nested/definitely/not-a-page.md')).toBeTruthy() + expect(getPathMatch(mdSuffixRule!.source)('/definitely-not-a-page')).toBe(false) + expect(getPathMatch(acceptRule!.source)('/definitely-not-a-page')).toBeTruthy() + + const acceptHeaderRegex = new RegExp(`^${acceptRule!.has![0].value}$`) + expect(acceptHeaderRegex.test('text/markdown')).toBe(true) + expect(acceptHeaderRegex.test('text/html, text/markdown;q=0.9')).toBe(true) + expect(acceptHeaderRegex.test('text/*')).toBe(true) + expect( + acceptHeaderRegex.test('text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') + ).toBe(false) + }) + it('permanently redirects the legacy root markdown aliases to /index.md', async () => { const { default: config } = (await import('./next.config.mjs')) as { default: NextConfig } const redirects = (await config.redirects?.()) || []
- {PIPELINE_REGION.displayName} - - {PIPELINE_REGION.code} - -