mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: blog post postgrest 13 (#38840)
* add CodeTabs component to www * add to www mdx components
This commit is contained in:
1 parent
360663d4f6
commit
cb66b6b635
17 files changed
+960
-339
No files matched your search
@@ -0,0 +1,204 @@
|
||||
---
|
||||
title: PostgREST 13
|
||||
description: New features and changes in PostgREST version 13.
|
||||
author: steve_chavez,laurenceisla,avallete
|
||||
image: 2025-09-30-postgrest-13-release/postgrest-13-release.png
|
||||
thumb: 2025-09-30-postgrest-13-release/postgrest-13-release.png
|
||||
categories:
|
||||
- postgres
|
||||
tags:
|
||||
- postgres
|
||||
- postgrest
|
||||
- release-notes
|
||||
date: '2025-09-30'
|
||||
---
|
||||
|
||||
PostgREST 13 is out! It comes with API and Observabilty improvements. In this post, we'll see what's new.
|
||||
|
||||
## Spread To-Many relationships
|
||||
|
||||
This new feature allows you to represent one-to-many and many-to-many relationships as flat JSON arrays.
|
||||
|
||||
For example, if you have database similar to IMDB and you’d like to represent it as a hierarchical JSON structure for your frontend, like so:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "The Shawshank Redemption",
|
||||
"actors": ["Tim Robbins", "Morgan Freeman"],
|
||||
"genres": ["Drama"]
|
||||
},
|
||||
{
|
||||
"title": "The Godfather",
|
||||
"actors": ["Marlon Brando", "Al Pacino"],
|
||||
"genres": ["Drama", "Crime"]
|
||||
},
|
||||
{
|
||||
"title": "The Dark Knight",
|
||||
"actors": ["Christian Bale", "Heath Ledger"],
|
||||
"genres": ["Drama", "Crime", "Action"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
You can now do it this way:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```js name=JavaScript
|
||||
const { data, error } = await supabase.from('titles').select(`
|
||||
title:primary_title,
|
||||
...people(actors:primary_name),
|
||||
...genres(genres:name)
|
||||
`)
|
||||
```
|
||||
|
||||
```bash name=Bash
|
||||
curl --get 'https://<your-domain>/titles'
|
||||
-d "select=title:primary_title,...people(actors:primary_name),...genres(genres:name)"
|
||||
-H "Authorization: Bearer <YOUR_KEY>"
|
||||
```
|
||||
|
||||
```swift name=Swift
|
||||
try await supabase.from("titles").select(
|
||||
"""
|
||||
title:primary_title,
|
||||
...people(actors:primary_name),
|
||||
...genres(genres:name)
|
||||
""")
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
The above `...people` is “spreading” the many-to-many relationship between `titles` and `people`, forming a flat array only consisting of the `primary_name` column. This flat array is then renamed to `actors`. We do a similar process for `genres` , which also forms a many-to-many relationship with `people`.
|
||||
|
||||
You can see the data model used for this example on this [gist](https://gist.github.com/steve-chavez/93f7ae04b4323e1952710af7129b32cf). There are more details about this feature on the [official docs](https://docs.postgrest.org/en/v13/references/api/resource_embedding.html#spread-to-many-relationships).
|
||||
|
||||
## Automatic tsvector convertion
|
||||
|
||||
Previously you could only use the full text search operator on `tsvector` columns, now you can do it on `text` and `json/jsonb` columns too:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```js name=JavaScript
|
||||
const { data, error } = await supabase.from('titles').textSearch('primary_name', `'god' & 'father'`)
|
||||
```
|
||||
|
||||
```bash name=Bash
|
||||
curl --get 'https://<your-domain>/titles'
|
||||
-d "primary_name=fts.'god'%26'father"
|
||||
-H "Authorization: Bearer <YOUR_KEY>"
|
||||
```
|
||||
|
||||
```swift name=Swift
|
||||
try await supabase
|
||||
.from("titles")
|
||||
.fts("primary_name",value: "'god' & 'father'")
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
This works because `text` and `json/jsonb` columns will be automatically converted with `to_tsvector`.
|
||||
|
||||
To ensure this operation is fast, add an index:
|
||||
|
||||
```sql
|
||||
create index idx_titles on people
|
||||
using gin (to_tsvector('english', primary_name));
|
||||
```
|
||||
|
||||
## Max Affected
|
||||
|
||||
You can now limit to the amount of rows affected by an `update` or `delete` operation with `maxAffected`:
|
||||
|
||||
<$CodeTabs>
|
||||
|
||||
```js name=JavaScript
|
||||
const { data, error } = await supabase
|
||||
.from('people')
|
||||
.update({ primary_name: 'Marlon Brando Jr.' })
|
||||
.eq('nconst', 'nm0000008')
|
||||
.maxAffected(1)
|
||||
|
||||
// This is available starting from supabase-js version 2.56.0
|
||||
```
|
||||
|
||||
```bash name=Bash
|
||||
curl -X PATCH 'https://<your-domain>/people' \
|
||||
-d "nconst=eq.nm0000008" \
|
||||
-H "Authorization: Bearer <YOUR_KEY>" \
|
||||
-H "Prefer: handling=strict, max-affected=1" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- <<JSON
|
||||
{ primary_name: 'Marlon Brando Jr.' }
|
||||
JSON
|
||||
```
|
||||
|
||||
```swift name=Swift
|
||||
try await supabase
|
||||
.from("people")
|
||||
.update(["primary_name": "Marlon Brando Jr."])
|
||||
.eq("nconst", value: "nm0000008")
|
||||
.maxAffected(1)
|
||||
.execute()
|
||||
|
||||
// This is available starting from supabase-swift version 2.33.0
|
||||
```
|
||||
|
||||
</$CodeTabs>
|
||||
|
||||
If the rows affected by the operation surpass the limit in `maxAffected`, an error will be thrown.
|
||||
|
||||
This also works with `rpc()`, given that it modifies rows and returns the affected rows. More on details on the [official docs](https://docs.postgrest.org/en/v13/references/api/preferences.html#max-affected).
|
||||
|
||||
## Content-Length header
|
||||
|
||||
For observability, you can now verify the response body size in bytes in the `Content-Length` header.
|
||||
|
||||
```bash
|
||||
HTTP/1.1 200 OK
|
||||
Content-Length: 104
|
||||
Content-Location: /items
|
||||
```
|
||||
|
||||
This helps in cases where you want to know which requests consume the most traffic to avoid exceeding egress limits.
|
||||
|
||||
## Proxy-Status header
|
||||
|
||||
The PostgREST error code is now present in the `Proxy-Status` header.
|
||||
|
||||
```bash
|
||||
HTTP/1.1 406 Not Acceptable
|
||||
Proxy-Status: PostgREST; error=PGRST116
|
||||
```
|
||||
|
||||
You can check the `Proxy-Status` and `Content-Length` headers in the Supabase Logs Explorer.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
### JWT `kid` validation
|
||||
|
||||
PostgREST now validates the JWT `kid` claim. If your JWT contains a Key ID (`kid`), it will try to match this with one of the `kid`'s in the configured JSON Web Key Set. Check the [official docs](https://docs.postgrest.org/en/v13/references/auth.html#jwk-kid-validation) for more details.
|
||||
|
||||
If you use Supabase Auth or the CLI to create JSON Web Keys, you shouldn’t worry about this change as both systems will ensure `kid`'s are present in the JSON Web Key Set.
|
||||
|
||||
For users that integrate with other Auth systems, make sure that both your JWT and JWKS follow the above rules.
|
||||
|
||||
### Schema validation in PostgREST search path
|
||||
|
||||
The schemas inside `db-schemas` and `db-extra-search-path` are now validated. This means you cannot put a nonexistent schema there, if you do PostgREST will fail with an error message.
|
||||
|
||||
If you drop a schema during a migration, you should make sure this is synced with the PostgREST search path, which is possible thanks to postgres transactional DDL:
|
||||
|
||||
```sql
|
||||
begin;
|
||||
drop schema old_schema;
|
||||
alter role authenticator set pgrst.db_schemas = 'public, pg_graphql, others'; -- make sure old_schema is not present here
|
||||
commit;
|
||||
```
|
||||
|
||||
## Try it out
|
||||
|
||||
PostgREST v13 is now available for all new projects on the Supabase platform, old projects can upgrade to get this new version.
|
||||
|
||||
You can look at the full changelog on the [release notes](https://github.com/PostgREST/postgrest/releases/tag/v13.0.0).
|
||||
@@ -39,7 +39,7 @@ async function getCMSPostFromAPI(
|
||||
const response = await fetch(url.toString(), fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[getCMSPostFromAPI] Non-OK response:', response.status)
|
||||
console.warn('[getCMSPostFromAPI] Non-OK response:', response.status)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ async function getCMSPostFromAPI(
|
||||
|
||||
return data.success ? data.post : null
|
||||
} catch (error) {
|
||||
console.error('[getCMSPostFromAPI] Error:', error)
|
||||
console.warn('[getCMSPostFromAPI] Error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,7 @@ export default async function BlogPostPage({ params }: { params: Promise<Params>
|
||||
try {
|
||||
processedContent = await processCMSContent(cmsPost.richContent || cmsPost.content, tocDepth)
|
||||
} catch (error) {
|
||||
console.error('Error processing CMS content, falling back to legacy processing:', error)
|
||||
console.warn('Error processing CMS content, falling back to legacy processing:', error)
|
||||
// Fallback to legacy processing
|
||||
const mdxSource = await mdxSerialize(cmsPost.content || '', { tocDepth })
|
||||
processedContent = {
|
||||
|
||||
@@ -13,9 +13,10 @@ import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql'
|
||||
import yaml from 'react-syntax-highlighter/dist/cjs/languages/hljs/yaml'
|
||||
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json'
|
||||
import { Button, cn } from 'ui'
|
||||
import monokaiCustomTheme from './CodeBlock.utils'
|
||||
import monokaiCustomTheme, { codeHikeTheme } from './CodeBlock.utils'
|
||||
|
||||
export type LANG = 'js' | 'sql' | 'py' | 'bash' | 'ts' | 'tsx' | 'kotlin' | 'yaml' | 'json'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
lang: LANG
|
||||
startingLineNumber?: number
|
||||
@@ -25,11 +26,13 @@ export interface CodeBlockProps {
|
||||
children?: string
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
background?: string
|
||||
filename?: string
|
||||
theme?: 'monokai' | 'code-hike'
|
||||
}
|
||||
|
||||
function CodeBlock(props: CodeBlockProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDarkTheme = resolvedTheme?.includes('dark')!
|
||||
const isDarkTheme = resolvedTheme?.includes('dark') ?? false
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
@@ -51,6 +54,8 @@ function CodeBlock(props: CodeBlockProps) {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const isCodeHikeTheme = props.theme === 'code-hike'
|
||||
|
||||
let lang = props.lang
|
||||
? props.lang
|
||||
: props.className
|
||||
@@ -111,7 +116,15 @@ function CodeBlock(props: CodeBlockProps) {
|
||||
{/* @ts-ignore */}
|
||||
<SyntaxHighlighter
|
||||
language={lang}
|
||||
style={isDarkTheme ? monokaiCustomTheme.dark : monokaiCustomTheme.light}
|
||||
style={
|
||||
isCodeHikeTheme
|
||||
? isDarkTheme
|
||||
? codeHikeTheme.dark
|
||||
: codeHikeTheme.light
|
||||
: isDarkTheme
|
||||
? monokaiCustomTheme.dark
|
||||
: monokaiCustomTheme.light
|
||||
}
|
||||
className={cn(
|
||||
'synthax-highlighter border border-default/15 rounded-lg',
|
||||
!filename && 'rounded-t-lg',
|
||||
@@ -132,9 +145,9 @@ function CodeBlock(props: CodeBlockProps) {
|
||||
showLineNumbers={props.showLineNumbers}
|
||||
lineNumberStyle={{
|
||||
padding: '0px',
|
||||
marginRight: '21px',
|
||||
marginRight: isCodeHikeTheme ? '16px' : '21px',
|
||||
minWidth: '1.5em',
|
||||
opacity: '0.3',
|
||||
opacity: isCodeHikeTheme ? '0.7' : '0.3',
|
||||
fontSize: large ? 14 : '0.75rem',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -229,4 +229,237 @@ const monokaiCustomTheme = {
|
||||
},
|
||||
}
|
||||
|
||||
// CodeHike theme based on the colors from code-hike.scss
|
||||
const codeHikeTheme = {
|
||||
dark: {
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
background: '#2a2929', // --ch-16 dark
|
||||
color: '#f8f8f2', // --ch-4
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-strong': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#79c0ff', // --ch-2
|
||||
},
|
||||
'hljs-code': {
|
||||
color: '#569cd6', // --ch-7
|
||||
},
|
||||
'hljs-class .hljs-title': {
|
||||
color: '#8b949e', // --ch-1
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#bda4ff', // --ch-5
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#bda4ff', // --ch-5
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#bda4ff', // --ch-5
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#bda4ff', // --ch-5
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-selector-attr': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-selector-pseudo': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#ffcda1', // --ch-3
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#8b949e', // --ch-1
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#8b949e', // --ch-1
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#8b949e', // --ch-1
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#8b949e', // --ch-1
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
hljs: {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
background: '#eeeeee', // --ch-16 light
|
||||
color: '#24292f', // --ch-4
|
||||
},
|
||||
'hljs-tag': {
|
||||
color: '#0550ae', // --ch-2
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#0550ae', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#0550ae', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#0550ae', // --ch-2
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-strong': {
|
||||
color: '#0550ae', // --ch-2
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#0550ae', // --ch-2
|
||||
},
|
||||
'hljs-code': {
|
||||
color: '#cf222e', // --ch-7
|
||||
},
|
||||
'hljs-class .hljs-title': {
|
||||
color: '#6e7781', // --ch-1
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#8250df', // --ch-5
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#8250df', // --ch-5
|
||||
},
|
||||
'hljs-regexp': {
|
||||
color: '#8250df', // --ch-5
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#8250df', // --ch-5
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#953800', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#953800', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#953800', // --ch-3
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-built_in': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-builtin-name': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-selector-attr': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-selector-pseudo': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#953800', // --ch-3
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#6e7781', // --ch-1
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6e7781', // --ch-1
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#6e7781', // --ch-1
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#6e7781', // --ch-1
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'hljs-selector-id': {
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export { codeHikeTheme }
|
||||
export default monokaiCustomTheme
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
export function NamedCodeBlock({ name, children }: PropsWithChildren<{ name: string }>) {
|
||||
return (
|
||||
<div className="shiki-wrapper w-full space-y-2">
|
||||
<h6
|
||||
className={cn(
|
||||
'w-fit flex items-center text-center',
|
||||
'shadow-sm rounded border border-stronger bg-selection',
|
||||
'px-2.5 py-1',
|
||||
'text-xs text-foreground'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</h6>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NamedCodeBlock } from './CodeTabs.components'
|
||||
export { preprocessMdxWithCodeTabs } from './utils'
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Simple string-based transformation for CodeTabs
|
||||
* This avoids the complex AST manipulation that's causing issues
|
||||
*/
|
||||
|
||||
export function transformCodeTabs(mdx: string): string {
|
||||
// Pattern to match <$CodeTabs>...</$CodeTabs> blocks
|
||||
const codeTabsPattern = /<\$CodeTabs>([\s\S]*?)<\/\$CodeTabs>/g
|
||||
|
||||
return mdx.replace(codeTabsPattern, (match, content) => {
|
||||
// Extract code blocks from the content
|
||||
const codeBlockPattern = /```(\w+)(?:\s+name=([^\s]+))?\s*\n([\s\S]*?)\n```/g
|
||||
const codeBlocks: Array<{ lang: string; name?: string; content: string }> = []
|
||||
|
||||
let blockMatch: RegExpExecArray | null
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: required for regex loop
|
||||
while ((blockMatch = codeBlockPattern.exec(content)) !== null) {
|
||||
const [, lang, name, blockContent] = blockMatch
|
||||
codeBlocks.push({
|
||||
lang,
|
||||
name: name || `${lang}.${lang === 'bash' ? 'sh' : lang}`,
|
||||
content: blockContent.trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
return match // Return original if no code blocks found
|
||||
}
|
||||
|
||||
// Generate the Tabs structure
|
||||
const tabPanels = codeBlocks
|
||||
.map(
|
||||
(block) => `
|
||||
<TabPanel id="${block.name}" label="${block.name}">
|
||||
|
||||
\`\`\`${block.lang}
|
||||
${block.content}
|
||||
\`\`\`
|
||||
|
||||
</TabPanel>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<Tabs listClassNames="flex-nowrap overflow-x-auto -mb-6">${tabPanels}
|
||||
</Tabs>`
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { transformCodeTabs } from './simple-transform'
|
||||
|
||||
export function preprocessMdxWithCodeTabs(mdx: string) {
|
||||
try {
|
||||
// Use simple string-based transformation
|
||||
const transformed = transformCodeTabs(mdx)
|
||||
return Promise.resolve(transformed)
|
||||
} catch (error) {
|
||||
console.error('preprocessMdxWithCodeTabs error:', error)
|
||||
// Fallback to returning the original MDX
|
||||
return Promise.resolve(mdx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, TabsContent_Shadcn_ } from 'ui'
|
||||
|
||||
export interface TabsProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
defaultActiveId?: string
|
||||
listClassNames?: string
|
||||
}
|
||||
|
||||
export interface TabPanelProps {
|
||||
children: React.ReactNode
|
||||
id: string
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Tabs({ children, className, defaultActiveId, listClassNames }: TabsProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState(defaultActiveId)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
// Extract tab panels from children
|
||||
const tabPanels = Array.isArray(children) ? children : [children]
|
||||
const validTabPanels = tabPanels.filter(
|
||||
(child) => child && typeof child === 'object' && 'props' in child && child.props.id
|
||||
)
|
||||
|
||||
if (validTabPanels.length === 0) return null
|
||||
|
||||
const currentActiveTab = activeTab || validTabPanels[0]?.props?.id
|
||||
|
||||
return (
|
||||
<div className={cn('not-prose', className)}>
|
||||
<Tabs_Shadcn_
|
||||
value={currentActiveTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full flex flex-col gap-4 [&_.ch-codeblock]:!mt-0"
|
||||
>
|
||||
<TabsList_Shadcn_ className={cn('shiki-wrapper flex', listClassNames)}>
|
||||
{validTabPanels.map((panel) => (
|
||||
<TabsTrigger_Shadcn_
|
||||
key={panel.props.id}
|
||||
value={panel.props.id}
|
||||
className="text-xs px-2.5"
|
||||
>
|
||||
{panel.props.label}
|
||||
</TabsTrigger_Shadcn_>
|
||||
))}
|
||||
</TabsList_Shadcn_>
|
||||
{validTabPanels.map((panel) => (
|
||||
<TabsContent_Shadcn_ key={panel.props.id} value={panel.props.id} className="mt-0">
|
||||
{panel.props.children}
|
||||
</TabsContent_Shadcn_>
|
||||
))}
|
||||
</Tabs_Shadcn_>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabPanel({ children, id, label, className }: TabPanelProps) {
|
||||
return (
|
||||
<div className={cn('tab-panel', className)} data-id={id} data-label={label}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabPanel }
|
||||
export default Tabs
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Tabs, TabPanel } from './Tabs'
|
||||
export type { TabsProps, TabPanelProps } from './Tabs'
|
||||
@@ -106,6 +106,14 @@
|
||||
"author_url": "https://github.com/laurenceisla",
|
||||
"author_image_url": "https://github.com/laurenceisla.png"
|
||||
},
|
||||
{
|
||||
"author_id": "avallete",
|
||||
"author": "Andrew Valleteau",
|
||||
"username": "avallete",
|
||||
"position": "Engineering",
|
||||
"author_url": "https://github.com/avallete",
|
||||
"author_image_url": "https://github.com/avallete.png"
|
||||
},
|
||||
{
|
||||
"author_id": "supabase",
|
||||
"author": "Supabase",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { CH } from '@code-hike/mdx/components'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ArrowUpRight, Triangle } from 'lucide-react'
|
||||
@@ -14,11 +13,26 @@ import {
|
||||
Image,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { ImageProps } from 'ui/src/components/Image/Image'
|
||||
|
||||
const Avatar = dynamic(() => import('~/components/Avatar'))
|
||||
const Chart = dynamic(() => import('~/components/Charts/PGCharts'))
|
||||
const CodeBlock = dynamic(() => import('~/components/CodeBlock/CodeBlock'))
|
||||
const Tabs = dynamic(() => import('~/components/Tabs/Tabs'), { ssr: false })
|
||||
const TabPanel = dynamic(
|
||||
() => import('~/components/Tabs/Tabs').then((mod) => ({ default: mod.TabPanel })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const NamedCodeBlock = dynamic(
|
||||
() =>
|
||||
import('~/components/CodeTabs').then((mod) => ({
|
||||
default: mod.NamedCodeBlock,
|
||||
})),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
)
|
||||
const ImageFadeStack = dynamic(() => import('~/components/ImageFadeStack'))
|
||||
const ImageGrid = dynamic(() => import('~/components/ImageGrid'))
|
||||
const InlineCodeTag = dynamic(() => import('~/components/InlineCode'))
|
||||
@@ -73,6 +87,9 @@ const BlogCollapsible = ({
|
||||
export default function mdxComponents(type?: 'blog' | 'lp' | undefined) {
|
||||
const components = {
|
||||
CodeBlock,
|
||||
Tabs,
|
||||
TabPanel,
|
||||
NamedCodeBlock,
|
||||
CH,
|
||||
h1: (props: any) => <Heading {...props} tag="h1" />,
|
||||
h2: (props: any) => <Heading {...props} tag="h2" />,
|
||||
@@ -110,6 +127,7 @@ export default function mdxComponents(type?: 'blog' | 'lp' | undefined) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
// biome-ignore lint/a11y/useAltText: provided in props
|
||||
return <img {...props} />
|
||||
},
|
||||
Img: ({ zoomable = true, className, ...props }: ImageProps & { wide?: boolean }) => (
|
||||
|
||||
@@ -2,8 +2,9 @@ import { serialize } from 'next-mdx-remote/serialize'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
import { CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx'
|
||||
import { type CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx'
|
||||
import codeHikeTheme from 'config/code-hike.theme.json' with { type: 'json' }
|
||||
import { preprocessMdxWithCodeTabs } from '~/components/CodeTabs'
|
||||
|
||||
// mdx2 needs self-closing tags.
|
||||
// dragging an image onto a GitHub discussion creates an <img>
|
||||
@@ -64,13 +65,18 @@ function createRemarkCollectToc(maxDepth: number) {
|
||||
walk(tree)
|
||||
|
||||
// Attach to tree data for retrieval post-serialize
|
||||
;(tree.data || (tree.data = {})).__collectedToc = items
|
||||
if (!tree.data) {
|
||||
tree.data = {}
|
||||
}
|
||||
tree.data.__collectedToc = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mdxSerialize(source: string, options?: { tocDepth?: number }) {
|
||||
const formattedSource = addSelfClosingTags(source)
|
||||
// Preprocess MDX to handle CodeTabs transformation
|
||||
const preprocessedSource = await preprocessMdxWithCodeTabs(formattedSource)
|
||||
const codeHikeOptions: CodeHikeConfig = {
|
||||
theme: codeHikeTheme,
|
||||
lineNumbers: true,
|
||||
@@ -82,7 +88,7 @@ export async function mdxSerialize(source: string, options?: { tocDepth?: number
|
||||
const tocDepth = options?.tocDepth ?? 2
|
||||
let collectedToc: TocItem[] = []
|
||||
|
||||
const mdxSource: any = await serialize(formattedSource, {
|
||||
const mdxSource = await serialize(preprocessedSource, {
|
||||
scope: {
|
||||
chCodeConfig: codeHikeOptions,
|
||||
},
|
||||
@@ -91,10 +97,10 @@ export async function mdxSerialize(source: string, options?: { tocDepth?: number
|
||||
[remarkCodeHike, codeHikeOptions],
|
||||
remarkGfm,
|
||||
// Collect headings into a simple TOC structure
|
||||
function () {
|
||||
() => {
|
||||
const plugin = createRemarkCollectToc(tocDepth)
|
||||
const transformer = (plugin as any)()
|
||||
return function (tree: any) {
|
||||
return (tree: any) => {
|
||||
transformer(tree)
|
||||
if (tree?.data?.__collectedToc) {
|
||||
collectedToc = tree.data.__collectedToc as TocItem[]
|
||||
|
||||
+11
-1
@@ -26,11 +26,11 @@
|
||||
"@next/bundle-analyzer": "15.3.1",
|
||||
"@next/mdx": "15.3.1",
|
||||
"@octokit/auth-app": "^7.0.0",
|
||||
"@payloadcms/live-preview-react": "^3.0.0",
|
||||
"@octokit/core": "^6.0.0",
|
||||
"@octokit/graphql": "^8.0.0",
|
||||
"@octokit/plugin-paginate-graphql": "^4.0.0",
|
||||
"@octokit/rest": "^21.0.0",
|
||||
"@payloadcms/live-preview-react": "^3.0.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
@@ -52,6 +52,13 @@
|
||||
"icons": "workspace:*",
|
||||
"lucide-react": "*",
|
||||
"markdown-toc": "^1.2.0",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-gfm": "^2.0.2",
|
||||
"mdast-util-mdx": "^2.0.1",
|
||||
"mdast-util-mdx-jsx": "^2.1.4",
|
||||
"mdast-util-to-markdown": "^1.5.0",
|
||||
"micromark-extension-gfm": "^2.0.3",
|
||||
"micromark-extension-mdxjs": "^1.0.1",
|
||||
"next": "catalog:",
|
||||
"next-mdx-remote": "^4.4.1",
|
||||
"next-seo": "^6.5.0",
|
||||
@@ -86,6 +93,7 @@
|
||||
"@types/animejs": "^3.1.12",
|
||||
"@types/classnames": "^2.3.1",
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/mdx-js__react": "^1.5.6",
|
||||
"@types/parse-numeric-range": "^0.0.1",
|
||||
"@types/react": "catalog:",
|
||||
@@ -97,7 +105,9 @@
|
||||
"api-types": "workspace:*",
|
||||
"postcss": "^8.5.3",
|
||||
"react-hook-form": "^7.45.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsconfig": "workspace:*",
|
||||
"unist-util-visit-parents": "5.1.3",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -2162,6 +2162,18 @@
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/defense-in-depth-mcp</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/blog/postgrest-13-release</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://supabase.com/customers/asap-work</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
|
||||
Generated
+278
-324
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user