diff --git a/README.md b/README.md index add5ed40..8da6af84 100755 --- a/README.md +++ b/README.md @@ -524,9 +524,7 @@ sbBridge.on(['input', 'published', 'change'], (event) => { ## Rendering Rich Text -> [!WARNING] -> We have identified issues with richtext and Types on React 19 and Next.js 15. As a temporary measure, we advise you to continue using React 18 and Next.js 14 until we have fully resolved the issues. - +### Client-side Rich Text Rendering You can render rich text fields by using the `StoryblokRichText` component: ```ts @@ -569,7 +567,61 @@ function App() { } ``` -For a comprehensive list of options you can provide to the `useStoryblokRichText`, please consult the [Full options](https://github.com/storyblok/richtext?tab=readme-ov-file#options) documentation. +### Server-side Rich Text Rendering + +> [!INFO] +> Recommended for Next.js 15 and React 19. + +You can render rich text fields by using the `StoryblokServerRichText` component: + +```ts +import { StoryblokServerRichText, StoryblokStory } from '@storyblok/react/rsc'; +import { StoryblokClient, ISbStoriesParams } from '@storyblok/react'; +import { getStoryblokApi } from '@/lib/storyblok'; // Remember to import from the local file + +async function App() { + const { data } = await fetchData(); + + if (!data?.story?.content) { + return
Loading...
; + } + + return ( +
+ +
+ ); +} + +export async function fetchData() { + let sbParams: ISbStoriesParams = { version: 'draft' }; + + const storyblokApi: StoryblokClient = getStoryblokApi(); + return storyblokApi.get(`cdn/stories/home`, sbParams); +} +``` + +Or you can have more control by using the `useStoryblokServerRichText` hook: + +```ts +import { useStoryblokServerRichText, convertAttributesInElement } from '@storyblok/react'; +import Codeblock from './Codeblock'; + +function App() { + const { render } = useStoryblokServerRichText({ + // options like resolvers + }); + + const html = render(doc); + const formattedHtml = convertAttributesInElement(html as React.ReactElement); // JSX + + return ( +
+ {formattedHtml} +
+ ); +} +``` ### Overriding the default resolvers diff --git a/playground/next15/.gitignore b/playground/next15/.gitignore index d32cc78b..44fdeff2 100644 --- a/playground/next15/.gitignore +++ b/playground/next15/.gitignore @@ -38,3 +38,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates \ No newline at end of file diff --git a/playground/next15/package.json b/playground/next15/package.json index 4fd666b7..4633038f 100644 --- a/playground/next15/package.json +++ b/playground/next15/package.json @@ -3,16 +3,20 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --experimental-https", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { "@storyblok/react": "workspace:^", + "@tailwindcss/postcss": "^4.0.14", + "@tailwindcss/typography": "^0.5.16", "next": "15.3.2", + "postcss": "^8.5.3", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "tailwindcss": "^4.0.14" }, "devDependencies": { "@types/node": "^22", diff --git a/playground/next15/postcss.config.mjs b/playground/next15/postcss.config.mjs new file mode 100644 index 00000000..05466d5f --- /dev/null +++ b/playground/next15/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ + +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; +export default config; diff --git a/playground/next15/src/app/globals.css b/playground/next15/src/app/globals.css index e3734be1..b01bdf43 100644 --- a/playground/next15/src/app/globals.css +++ b/playground/next15/src/app/globals.css @@ -1,3 +1,10 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; + +span[data-type='emoji'] img { + @apply m-0; +} + :root { --background: #ffffff; --foreground: #171717; @@ -24,10 +31,13 @@ body { -moz-osx-font-smoothing: grayscale; } -* { - box-sizing: border-box; - padding: 0; - margin: 0; +@layer base { + * { + margin: 0; + padding: 0; + box-sizing: border-box; + scroll-behavior: smooth; + } } a { diff --git a/playground/next15/src/app/layout.tsx b/playground/next15/src/app/layout.tsx index 923b47a8..adcfa545 100644 --- a/playground/next15/src/app/layout.tsx +++ b/playground/next15/src/app/layout.tsx @@ -1,4 +1,5 @@ import StoryblokProvider from '@/components/StoryblokProvider'; +import '../app/globals.css'; export const metadata = { title: 'Create Next App', diff --git a/playground/next15/src/app/page.tsx b/playground/next15/src/app/page.tsx index b726adad..6fba5608 100644 --- a/playground/next15/src/app/page.tsx +++ b/playground/next15/src/app/page.tsx @@ -1,105 +1,34 @@ -import type { - ISbStoriesParams, - StoryblokClient, - StoryblokRichTextNode, -} from '@storyblok/react/rsc'; -import { MarkTypes, StoryblokRichText, StoryblokStory, -} from '@storyblok/react/rsc'; -import { getStoryblokApi } from '@/lib/storyblok'; +import type { ISbStoriesParams, StoryblokClient } from '@storyblok/react/rsc'; +import { StoryblokStory } from '@storyblok/react/rsc'; +import { getStoryblokApi } from '@/lib/storyblok'; // Remember to import from the local file import Link from 'next/link'; -import type { ReactElement } from 'react'; export default async function Home() { const { data } = await fetchData(); - const doc = { - type: 'doc', - content: [ - { - type: 'paragraph', - content: [ - { - type: 'text', - text: 'This is a test of the StoryblokRichText component.', - }, - ], - }, - { - type: 'paragraph', - content: [ - { - text: 'Internal Link', - type: 'text', - marks: [ - { - type: 'link', - attrs: { - href: '/', - uuid: '8489bed8-d86f-4fde-965c-e3d748e12147', - anchor: null, - target: '_self', - linktype: 'story', - }, - }, - ], - }, - ], - }, - { - type: 'paragraph', - content: [ - { - text: 'External link', - type: 'text', - marks: [ - { - type: 'link', - attrs: { - href: 'https://alvarosaburido.dev', - uuid: null, - anchor: null, - target: '_blank', - linktype: 'url', - }, - }, - ], - }, - ], - }, - ], - }; - const resolvers = { - // custom resolvers - [MarkTypes.LINK]: (node: StoryblokRichTextNode) => { - return node.attrs?.linktype === 'story' - ? ( - - {node.text} - - ) - : ( - - {node.text} - - ); - }, - }; - return ( -
-

- Story: - {data.story.id} -

- - -
+
+
+

+ Storyblok Next.js 15 Example +

+ + + + {data.story && ( +
+ +
+ )} +
+
); } @@ -107,5 +36,5 @@ export async function fetchData() { const sbParams: ISbStoriesParams = { version: 'draft' }; const storyblokApi: StoryblokClient = getStoryblokApi(); - return storyblokApi.get(`cdn/stories/home`, sbParams); + return storyblokApi.get(`cdn/stories/react`, sbParams); } diff --git a/playground/next15/src/app/richtext/page.tsx b/playground/next15/src/app/richtext/page.tsx new file mode 100644 index 00000000..69b07e50 --- /dev/null +++ b/playground/next15/src/app/richtext/page.tsx @@ -0,0 +1,37 @@ +import type { ISbStoriesParams, StoryblokClient } from '@storyblok/react/rsc'; +import { getStoryblokApi } from '@/lib/storyblok'; +import { StoryblokServerRichText } from '@storyblok/react/rsc'; + +export default async function RichtextPage() { + const { data } = await fetchData(); + + if (!data.story?.content) { + return ( +
+
+ Loading content... +
+
+ ); + } + + return ( +
+

Rich Text Example

+ {data.story.content.richText + ? ( + + ) + : ( +

No content available

+ )} +
+ ); +} + +export async function fetchData() { + const sbParams: ISbStoriesParams = { version: 'draft' }; + + const storyblokApi: StoryblokClient = getStoryblokApi(); + return storyblokApi.get(`cdn/stories/react/test-richtext`, sbParams); +} diff --git a/playground/next15/src/components/EmojiRandomizer.tsx b/playground/next15/src/components/EmojiRandomizer.tsx new file mode 100644 index 00000000..17fd9c61 --- /dev/null +++ b/playground/next15/src/components/EmojiRandomizer.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React, { type FC, useState } from 'react'; +import type { SbBlokData } from '@storyblok/react'; + +interface EmojiRandomizerProps { + blok: SbBlokData & { + label?: string; + }; +} + +/** + * A component that displays a label and a random emoji that changes on click + */ +const EmojiRandomizer: FC = ({ blok }) => { + // List of fun emojis to randomly choose from + const emojis = ['😊', '🎉', '🚀', '✨', '🌈', '🎨', '🎸', '🎮', '🍕', '🌺']; + + // State to track current emoji + const [currentEmoji, setCurrentEmoji] = useState(() => + emojis[Math.floor(Math.random() * emojis.length)], + ); + + /** + * Generates a new random emoji different from the current one + */ + const randomizeEmoji = () => { + let newEmoji; + do { + newEmoji = emojis[Math.floor(Math.random() * emojis.length)]; + } while (newEmoji === currentEmoji); + + setCurrentEmoji(newEmoji); + }; + + return ( +
+
+ {currentEmoji} +
+ +
+ ); +}; + +export default EmojiRandomizer; diff --git a/playground/next15/src/lib/storyblok.ts b/playground/next15/src/lib/storyblok.ts index 378deaa9..a7f120e6 100644 --- a/playground/next15/src/lib/storyblok.ts +++ b/playground/next15/src/lib/storyblok.ts @@ -1,3 +1,4 @@ +import EmojiRandomizer from '@/components/EmojiRandomizer'; import Grid from '@/components/Grid'; import IFrameEmbed from '@/components/IFrameEmbed'; import Page from '@/components/Page'; @@ -12,5 +13,6 @@ export const getStoryblokApi = storyblokInit({ 'page': Page, 'grid': Grid, 'iframe-embed': IFrameEmbed, + 'emoji-randomizer': EmojiRandomizer, }, }); diff --git a/playground/react/components/emoji-randomizer.tsx b/playground/react/components/emoji-randomizer.tsx new file mode 100644 index 00000000..8bde99ad --- /dev/null +++ b/playground/react/components/emoji-randomizer.tsx @@ -0,0 +1,49 @@ +import React, { type FC, useState } from 'react'; +import type { SbBlokData } from '@storyblok/react'; + +interface EmojiRandomizerProps { + blok: SbBlokData & { + label?: string; + }; +} + +/** + * A component that displays a label and a random emoji that changes on click + */ +const EmojiRandomizer: FC = ({ blok }) => { + // List of fun emojis to randomly choose from + const emojis = ['😊', '🎉', '🚀', '✨', '🌈', '🎨', '🎸', '🎮', '🍕', '🌺']; + + // State to track current emoji + const [currentEmoji, setCurrentEmoji] = useState(() => + emojis[Math.floor(Math.random() * emojis.length)], + ); + + /** + * Generates a new random emoji different from the current one + */ + const randomizeEmoji = () => { + let newEmoji; + do { + newEmoji = emojis[Math.floor(Math.random() * emojis.length)]; + } while (newEmoji === currentEmoji); + + setCurrentEmoji(newEmoji); + }; + + return ( +
+
+ {currentEmoji} +
+ +
+ ); +}; + +export default EmojiRandomizer; diff --git a/playground/react/components/iframe-embed.tsx b/playground/react/components/iframe-embed.tsx deleted file mode 100644 index 63e53a2f..00000000 --- a/playground/react/components/iframe-embed.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import type { SbBlokData } from '@storyblok/react'; -import { storyblokEditable } from '@storyblok/react'; - -interface IframeEmbedProps { - blok: SbBlokData; -} - -const IFrameEmbed = ({ blok }: IframeEmbedProps) => { - return ( -
-
-