Skip to content

Commit

Permalink
Merge pull request #685 from storyblok/feature/add-storyblok-richtext
Browse files Browse the repository at this point in the history
feat: added new storyblok rich text API
  • Loading branch information
alvarosabu authored Sep 10, 2024
2 parents 7dac9dc + f4491e8 commit b8b2385
Show file tree
Hide file tree
Showing 21 changed files with 1,709 additions and 10,371 deletions.
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@
> This plugin is for Vue 3. [Check out the docs for Vue 2 version](https://github.com/storyblok/storyblok-vue-2).
## Kickstart a new project

Are you eager to dive into coding? **[Follow these steps to kickstart a new project with Storyblok and Vue](https://www.storyblok.com/technologies#vue?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-vue)**, and get started in just a few minutes!

## 5-minute Tutorial

Are you looking for a hands-on, step-by-step tutorial? The **[Vue 5-minute Tutorial](https://www.storyblok.com/tp/add-a-headless-CMS-to-vuejs-in-5-minutes?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-vue)** has you covered! It provides comprehensive instructions on how to set up a Storyblok space and connect it to your Vue project.

## Installation
Expand Down Expand Up @@ -128,6 +130,98 @@ Check the available [apiOptions](https://www.storyblok.com/docs/api/content-deli

### Rendering Rich Text

You can render rich-text fields by using the `StoryblokRichtext` component:

```html
<script setup>
import { StoryblokRichtext } from "@storyblok/vue";
</script>

<template>
<StoryblokRichtext :doc="blok.articleContent" />
</template>
```

#### Overriding the default resolvers

You can override the default resolvers by passing a `resolver` prop to the `StoryblokRichText` component, for example, to use vue-router links or add a custom codeblok component: :

```html
<script setup>
import { type VNode, h } from "vue";
import { StoryblokRichText, BlockTypes, MarkTypes, type StoryblokRichTextNode } from "@storyblok/vue";
import { RouterLink } from "vue-router";
import CodeBlok from "./components/CodeBlok.vue";
const resolvers = {
// RouterLink example:
[MarkTypes.LINK]: (node: StoryblokRichTextNode<VNode>) => {
return node.attrs?.linktype === 'STORY'
? h(RouterLink, {
to: node.attrs?.href,
target: node.attrs?.target,
}, node.text)
: h('a', {
href: node.attrs?.href,
target: node.attrs?.target,
}, node.text)
},
// Custom code block component example:
[BlockTypes.CODE_BLOCK]: (node: Node) => {
return h(CodeBlock, {
class: node?.attrs?.class,
}, node.children)
},
}
</script>
<template>
<StoryblokRichText :doc="blok.articleContent" :resolvers="resolvers" />
</template>
```
Or you can have more control by using the `useStoryblokRichText` composable:
```html
<script setup>
import { type VNode, h } from "vue";
import { useStoryblokRichText, BlockTypes, MarkTypes, type StoryblokRichTextNode } from "@storyblok/vue";
import { RouterLink } from "vue-router";
const resolvers = {
// RouterLink example:
[MarkTypes.LINK]: (node: StoryblokRichTextNode<VNode>) => {
return node.attrs?.linktype === 'STORY'
? h(RouterLink, {
to: node.attrs?.href,
target: node.attrs?.target,
}, node.text)
: h('a', {
href: node.attrs?.href,
target: node.attrs?.target,
}, node.text)
},
}
const { render } = useStoryblokRichText({
resolvers,
})
const html = render(blok.articleContent);
</script>
<template>
<div v-html="html"></div>
</template>
```
For more incredible options you can pass to the `useStoryblokRichtext, please consult the [Full options](https://github.com/storyblok/richtext?tab=readme-ov-file#options) documentation.
### Legacy Rich Text Resolver
> [!WARNING]
> The legacy `richTextResolver` is soon to be deprecated. We recommend migrating to the new `useRichText` composable described above instead.
You can easily render rich text by using the `renderRichText` function that comes with `@storyblok/vue` and a Vue computed property:
```html
Expand Down
5 changes: 1 addition & 4 deletions lib/StoryblokComponent.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
<script setup lang="ts">
import { ref, resolveDynamicComponent, inject } from "vue";
import type { SbBlokData, SbVueSDKOptions } from "./types";
import type { SbComponentProps, SbVueSDKOptions } from "./types";
export interface SbComponentProps {
blok: SbBlokData;
}
const props = defineProps<SbComponentProps>();
const blokRef = ref();
Expand Down
18 changes: 18 additions & 0 deletions lib/components/StoryblokRichText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { VNode } from "vue";
import type { StoryblokRichTextNode } from "@storyblok/js";
import { useStoryblokRichText } from "../composables/useStoryblokRichText";
import type { StoryblokRichTextProps } from "../types";
const props = defineProps<StoryblokRichTextProps>();
const { render } = useStoryblokRichText({
resolvers: props.resolvers ?? {},
});
const root = () => render(props.doc as StoryblokRichTextNode<VNode>);
</script>

<template>
<root />
</template>
34 changes: 34 additions & 0 deletions lib/composables/useStoryblokRichText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { VNode } from "vue";
import { createTextVNode, h } from "vue";
import type {
StoryblokRichTextNode,
StoryblokRichTextNodeResolver,
StoryblokRichTextOptions,
} from "@storyblok/js";
import { BlockTypes, richTextResolver } from "@storyblok/js";
import StoryblokComponent from "../StoryblokComponent.vue";

const componentResolver: StoryblokRichTextNodeResolver<VNode> = (
node: StoryblokRichTextNode<VNode>
): VNode => {
return h(
StoryblokComponent,
{
blok: node?.attrs?.body[0],
id: node.attrs?.id,
},
node.children
);
};

export function useStoryblokRichText(options: StoryblokRichTextOptions<VNode>) {
const mergedOptions: StoryblokRichTextOptions<VNode> = {
renderFn: h,
textFn: createTextVNode,
resolvers: {
[BlockTypes.COMPONENT]: componentResolver,
...options.resolvers,
},
};
return richTextResolver<VNode>(mergedOptions);
}
97 changes: 96 additions & 1 deletion lib/cypress/components/index.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import Page from "@storyblok/vue-playground/components/Page.vue";
import Feature from "@storyblok/vue-playground/components/Feature.vue";
import MyCustomFallback from "@storyblok/vue-playground/components/MyCustomFallback.vue";
import { StoryblokVue, apiPlugin } from "@storyblok/vue";

import IframeEmbed from "../testing-components/IFrameEmbed.vue";
import { RouterLink } from "vue-router";
import Essential from "../testing-components/Essential.vue";
import RealApi from "../testing-components/RealApi.vue";
import RichText from "../testing-components/RichText.vue";

const prepare = (pluginOpts = {}, comp = Essential, globalOpts = {}) => {
mount(comp, {
Expand Down Expand Up @@ -195,4 +197,97 @@ describe("@storyblok/vue", () => {
);
});
});

describe("StoryblokRichText", () => {
it("Renders the rich text using StoryblokRichText component", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { Teaser, Grid, Page, Feature },
});

cy.get("[data-test=root]")
.children()
.find("h1")
.should("have.text", "Headline 1");
});

it("Should render headline tags correctly", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { Teaser, Grid, Page, Feature },
});

cy.get("[data-test=root]")
.children()
.find("h1")
.should("have.text", "Headline 1");
cy.get("[data-test=root]")
.children()
.find("h2")
.should("have.text", "Headline 2");
cy.get("[data-test=root]")
.children()
.find("h3")
.should("have.text", "Headline 3");
cy.get("[data-test=root]")
.children()
.find("h4")
.should("have.text", "Headline 4");
cy.get("[data-test=root]")
.children()
.find("h5")
.should("have.text", "Headline 5");
cy.get("[data-test=root]")
.children()
.find("h6")
.should("have.text", "Headline 6");
});

it("Should render images correctly", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { Teaser, Grid, Page, Feature },
});

cy.get("[data-test=root]")
.children()
.find("img")
.should(
"have.attr",
"src",
"https://a.storyblok.com/f/279818/710x528/c53330ed26/tresjs-doge.jpg"
);
});

it("Should render links correctly", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { Teaser, Grid, Page, Feature },
});

cy.get("[data-test=root]")
.children()
.find("a")
.should("have.attr", "href", "https://storyblok.com/");
});

it("should render a custom iframe-embed blok component", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { IframeEmbed },
});

cy.get("[data-test=root]")
.children()
.find("iframe")
.should("have.attr", "src", "https://storyblok.com/");
});

it("should redirect internal links", () => {
prepare({ use: [apiPlugin] }, RichText, {
components: { IframeEmbed, RouterLink },
});

cy.get("[data-test=root]")
.children()
.find("a")
.contains("Internal Link")
.should("have.attr", "href", "/vue/test");
});
});
});
15 changes: 15 additions & 0 deletions lib/cypress/testing-components/IFrameEmbed.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps({
blok: {
type: Object,
required: true,
},
});
</script>
<template>
<iframe
:src="blok.url.url"
class="w-full aspect-video"
frameborder="0"
></iframe>
</template>
10 changes: 10 additions & 0 deletions lib/cypress/testing-components/RichText.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script setup lang="ts">
import RichTextChild from "./RichTextChild.vue";
</script>
<template>
<div data-test="root">
<Suspense>
<RichTextChild />
</Suspense>
</div>
</template>
43 changes: 43 additions & 0 deletions lib/cypress/testing-components/RichTextChild.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script setup lang="ts">
import {
useStoryblok,
StoryblokRichText,
type StoryblokRichTextNode,
MarkTypes,
} from "@storyblok/vue";
import { type VNode, h } from "vue";
import { RouterLink } from "vue-router";
const story = await useStoryblok("vue/test-richtext", { version: "draft" });
const resolvers = {
[MarkTypes.LINK]: (node: StoryblokRichTextNode<VNode>) => {
return node.attrs?.linktype === "STORY"
? h(
RouterLink,
{
to: node.attrs?.href,
target: node.attrs?.target,
},
node.text
)
: h(
"a",
{
href: node.attrs?.href,
target: node.attrs?.target,
},
node.text
);
},
};
</script>

<template>
<h2>RichText</h2>
<!-- <pre>{{ story.content.richText }}</pre> -->
<StoryblokRichText
v-if="story.content"
:doc="story.content.richText"
:resolvers="resolvers"
/>
</template>
Loading

0 comments on commit b8b2385

Please sign in to comment.