storyblok / richtext

A custom javascript resolver for the Storyblok Richtext field.
MIT License
9 stars 3 forks source link

Custom Component Resolver (Vue) #115

Open babalugats76 opened 1 month ago

babalugats76 commented 1 month ago

Summary

I am using Nuxt 3/Vue and need help migrating from renderRichText to storyblok/richtext, especially when it comes to the resolution and rendering of custom components contained within richtext fields.

I realize that this project is relatively new and probably still under heavy development, but, I have reviewed the playgrounds and available materials closely and my use case does not appear to be adequately addressed. IMHO, the Vue playground is missing key design patterns that would seemingly be part of any Vue implementation of the module.

Previous Solution

My previous solution utilized renderRichText and the Vue3RuntimeTemplate modules (for dynamic resolution).

<template>
  <section
    v-editable="blok"
    :class="classes"
  >
    <Vue3RuntimeTemplate :template="resolvedRichText" />
  </section>
</template>

<script setup>
import Vue3RuntimeTemplate from 'vue3-runtime-template';
import { RichTextSchema } from '@storyblok/js';
import cloneDeep from 'clone-deep';

const props = defineProps({ blok: Object });
const mySchema = cloneDeep(RichTextSchema);

const resolvedRichText = computed(() =>
  renderRichText(props.blok.text, {
    schema: mySchema,
    resolver: (component, blok) => `<component is="${component}" :blok='${JSON.stringify(blok)}' />`,
  }),
);

const getProseVariant = (variant) => {
  const variants = {
    gray: 'prose-gray',
    lavender: 'prose-lavender',
    neutral: 'prose-neutral',
    primary: 'prose-primary',
    slate: 'prose-slate',
    stone: 'prose-stone',
    zinc: 'prose-zinc',
    card: 'prose-card',
  };
  return variants[variant] || '';
};

const classes = computed(() => {
  const baseClasses = 'rich no-underline prose-a:no-underline prose dark:prose-invert max-w-none';

  const variantClass = getProseVariant(props.blok?.variant);
  const lgClass = variantClass !== 'prose-card' ? 'lg:prose-lg prose-code:lg:text-xl' : '';
  return `${baseClasses} ${variantClass} ${lgClass}`;
});
</script>

<style>
.rich > div {
  @apply mt-[2em] mb-[2em];
}
</style>

Work-In-Progress

I started by creating a composable to handle the application-wide rendering. I am specifically trying to address handling of [BlockTypes.COMPONENT] in the resolver and mapping of the component prop to the nested components, i.e., Image, Accordion, and YouTube:

rich.ts

import { h, createTextVNode, Fragment } from 'vue';
import { BlockTypes, richTextResolver, type StoryblokRichTextNode, type StoryblokRichTextOptions } from '@storyblok/richtext';

import Accordion from '~/storyblok/Accordion.vue';
import Image from '~/storyblok/Image.vue';
import YouTube from '~/storyblok/YouTube.vue';

const renderer = ({ document }: any) => {
  const componentMap: Record<string, any> = {
    'image': Image,
    'you-tube': YouTube,
    'accordion': Accordion,
  };

  const options: StoryblokRichTextOptions<VNode> = {
    renderFn: h,
    textFn: createTextVNode,
    resolvers: {
      [BlockTypes.COMPONENT]: (node: StoryblokRichTextNode<VNode>) => {
        if (Array.isArray(node.attrs?.body)) {
          const children = node.attrs.body.map((blok: any) => {
            const component = componentMap[blok.component] || 'div';
            return h(component, { blok }, {
              default: () => blok.content
                ? blok.content.map((child: StoryblokRichTextNode<VNode>) => richTextResolver<VNode>(options)
                  .render(child))
                : [],
            });
          });
          return h(Fragment, {}, children); // Ensure single VNode returned
        }
        return h('div');
      },
    },
  };

  return richTextResolver<VNode>(options)
    .render(document);
};

const getProseVariant = (variant: string = ''): string => {
  const variants: { [key: string]: string } = {
    gray: 'prose-gray',
    lavender: 'prose-lavender',
    neutral: 'prose-neutral',
    primary: 'prose-primary',
    slate: 'prose-slate',
    stone: 'prose-stone',
    zinc: 'prose-zinc',
    card: 'prose-card',
  };
  return variants[variant] || '';
};

export const useRichText = (): {
  renderer: (document: any) => VNode
  getProseVariant: (variant?: string) => string
} => ({
  renderer,
  getProseVariant,
});

Then, I created a wrapper component that can be used to render the VNode returned from

RichTextRenderer.vue

<template>
  <component :is="renderedContent"></component>
</template>

<script setup lang="ts">
const props = defineProps<{ document: Record<string, any>}>();
const { renderer } = useRichText();
const renderedContent = computed<VNode | null>(() => renderer({ document: props.document }));
</script>

In turn, I use this component downstream; for example, in a custom Rich Storyblok component:

Rich.vue

<template>
  <section
    v-editable="blok"
    :class="classes"
  >
    <RichTextRenderer :document="blok.text" />
  </section>
</template>

<script setup lang="ts">
import { twJoin } from 'tailwind-merge';
import type { StoryblokRichTextNode } from '@storyblok/richtext';
import type { SbBlokData } from '~/types';

const props = defineProps<{ blok: SbBlokData & { text: StoryblokRichTextNode<string>, variant?: string, scale_text: boolean } }>();
const { getProseVariant } = useRichText();

const classes = computed(() => twJoin(
  'rich no-underline prose-a:no-underline prose dark:prose-invert max-w-none',
  getProseVariant(props.blok?.variant),
  props.blok.scale_text ? 'lg:prose-lg prose-code:lg:text-xl' : '',
));
</script>

<style>
.article-content > .rich > div {
  @apply mt-[2em] mb-[2em];
}
</style>

Questions

What is the right way to resolve custom components?

Here is what I am doing which seems onerous and WAY more complicated (Array seems clunky, etc.) than before:

resolvers: {
      [BlockTypes.COMPONENT]: (node: StoryblokRichTextNode<VNode>) => {
        if (Array.isArray(node.attrs?.body)) {
          const children = node.attrs.body.map((blok: any) => {
            const component = componentMap[blok.component] || 'div';
            return h(component, { blok }, {
              default: () => blok.content
                ? blok.content.map((child: StoryblokRichTextNode<VNode>) => richTextResolver<VNode>(options)
                  .render(child))
                : [],
            });
          });
          return h(Fragment, {}, children); // Ensure single VNode returned
        }
        return h('div');
      },
    },

Is there a better way to do dynamic resolution?

I tried a lot of things, but ultimately had to ditch the use of Vue3RuntimeTemplate and instead had to hard code the dependencies:

import Accordion from '~/storyblok/Accordion.vue';
import Image from '~/storyblok/Image.vue';
import YouTube from '~/storyblok/YouTube.vue';

Any help/guidance would be greatly appreciated.

alvarosabu commented 1 month ago

Hey there @babalugats76 thanks a lot for reaching out! I'm pretty happy to see more people using the package.

Yes, we are currently working to integrate it into our ecosystem so you can use it from the Storyblok Vue and Nuxt SDK.

About the custom component resolver, I have a snippet that would simplify your life big time, and its how currently we are implementing it on our Vue SDK

import { StoryblokComponent } from '@storyblok/vue`

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

Instead of doing the component map manually, take advantage of the StoryblokComponent.vue that comes in the @storyblok/vue package. Make sure you have your components inside of the storyblok directory tho. But should work just fine.

babalugats76 commented 1 month ago

@alvarosabu Thank you so much for the helpful and detailed response. Much appreciated. I will take what you have provided and see if I can incorporate, dropping something similar into resolvers, etc. The idea of resolving via StoryblokComponent is a much better idea. I will give that a shot and report back.

The reason why the code is so complicated is that I found that, when using the rich editor, body[0] is not always guaranteed. In my testing, the body could have multiple entries if the user inserted consecutive, adjacent components. Have you tried that? That was my principle concern if that makes sense.

alvarosabu commented 1 month ago

The reason why the code is so complicated is that I found that, when using the rich editor, body[0] is not always guaranteed. In my testing, the body could have multiple entries if the user inserted consecutive, adjacent components. Have you tried that? That was my principle concern if that makes sense.

Mmm, thats quite important feedback, do you have an example so I can reproduce where body has more than 1 entry?

babalugats76 commented 1 month ago

@alvarosabu Absolutemente.

Add two consecutive components in the rich text editor using your preferred technique:

rich-text-consecutive-components

This will result in story JSON similar to this:

{
  "story": {
    "name": "Test",
    "created_at": "2024-10-08T20:24:32.599Z",
    "published_at": "2024-10-11T19:38:28.669Z",
    "id": 12947931,
    "uuid": "cbb00947-c9cd-4ef2-9f2d-0a3fdfe0e0cb",
    "content": {
      "_uid": "99560d3d-ad9d-4b76-8e9e-4ff96c96f1bb",
      "body": [
        {
          "_uid": "1304e7f1-0cb5-4ca3-8b39-5f019a035f24",
          "text": {
            "type": "doc",
            "content": [
              {
                "type": "paragraph",
                "content": [
                  {
                    "text": "Before components",
                    "type": "text"
                  }
                ]
              },
              {
                "type": "blok",
                "attrs": {
                  "id": "dabd9c69-02a7-46d4-8654-6fd381470dd1",
                  "body": [
                    {
                      "_uid": "i-da6aeb2b-e0fe-4764-a507-413e7484cf00",
                      "class": "",
                      "float": "",
                      "image": {
                        "id": 922456,
                        "alt": "A close up of a bunch of crayons",
                        "name": "",
                        "focus": "",
                        "title": "Crayons",
                        "source": "",
                        "filename": "https://a-us.storyblok.com/f/1021359/5616x3744/8a8956ad0e/crayons.jpg",
                        "copyright": "Photo by Alexander Grey on Unsplash",
                        "fieldtype": "asset",
                        "meta_data": {
                          "alt": "A close up of a bunch of crayons",
                          "title": "Crayons",
                          "source": "",
                          "copyright": "Photo by Alexander Grey on Unsplash"
                        },
                        "is_external_url": false
                      },
                      "width": "",
                      "height": "",
                      "rounded": "",
                      "component": "image",
                      "grayscale": false,
                      "responsive": true,
                      "_editable": "\u003C!--#storyblok#{\"name\": \"image\", \"space\": \"1021359\", \"uid\": \"i-da6aeb2b-e0fe-4764-a507-413e7484cf00\", \"id\": \"12947931\"}--\u003E"
                    },
                    {
                      "_uid": "i-dfcfac59-58a9-4ec3-9c02-9dce44f53800",
                      "class": "",
                      "float": "",
                      "image": {
                        "id": 922456,
                        "alt": "A close up of a bunch of crayons",
                        "name": "",
                        "focus": "",
                        "title": "Crayons",
                        "source": "",
                        "filename": "https://a-us.storyblok.com/f/1021359/5616x3744/8a8956ad0e/crayons.jpg",
                        "copyright": "Photo by Alexander Grey on Unsplash",
                        "fieldtype": "asset",
                        "meta_data": {
                          "alt": "A close up of a bunch of crayons",
                          "title": "Crayons",
                          "source": "",
                          "copyright": "Photo by Alexander Grey on Unsplash"
                        },
                        "is_external_url": false
                      },
                      "width": "",
                      "height": "",
                      "rounded": "",
                      "component": "image",
                      "grayscale": false,
                      "responsive": true,
                      "_editable": "\u003C!--#storyblok#{\"name\": \"image\", \"space\": \"1021359\", \"uid\": \"i-dfcfac59-58a9-4ec3-9c02-9dce44f53800\", \"id\": \"12947931\"}--\u003E"
                    }
                  ]
                }
              },
              {
                "type": "paragraph",
                "content": [
                  {
                    "text": "After components",
                    "type": "text"
                  }
                ]
              }
            ]
          },
          "variant": "lavender",
          "component": "rich-text",
          "scaleText": true,
          "_editable": "\u003C!--#storyblok#{\"name\": \"rich-text\", \"space\": \"1021359\", \"uid\": \"1304e7f1-0cb5-4ca3-8b39-5f019a035f24\", \"id\": \"12947931\"}--\u003E"
        }
      ],
      "component": "page",
      "metaTitle": "This is the headline",
      "metaDescription": "This is the byline",
      "_editable": "\u003C!--#storyblok#{\"name\": \"page\", \"space\": \"1021359\", \"uid\": \"99560d3d-ad9d-4b76-8e9e-4ff96c96f1bb\", \"id\": \"12947931\"}--\u003E"
    },
    "slug": "test",
    "full_slug": "test",
    "sort_by_date": null,
    "position": -140,
    "tag_list": [],
    "is_startpage": false,
    "parent_id": null,
    "meta_data": null,
    "group_id": "1f75f5da-3105-452e-8cb5-73406e1b20c1",
    "first_published_at": "2024-10-08T23:49:37.001Z",
    "release_id": null,
    "lang": "default",
    "path": null,
    "alternates": [],
    "default_full_slug": null,
    "translated_slugs": null
  },
  "cv": 1729449138,
  "rels": [],
  "links": []
}
konstantin-karlovich-unbiased-co-uk commented 1 month ago

The reason why the code is so complicated is that I found that, when using the rich editor, body[0] is not always guaranteed. In my testing, the body could have multiple entries if the user inserted consecutive, adjacent components. Have you tried that? That was my principle concern if that makes sense.

Mmm, thats quite important feedback, do you have an example so I can reproduce where body has more than 1 entry?

You have an example in playground https://github.com/storyblok/richtext/blob/main/playground/vue/src/components/HomeView.vue#L353

babalugats76 commented 1 month ago

@konstantin-karlovich-unbiased-co-uk Thank you for the feedback! However, I have already taken a look at that. To me, this is not an example of my use case nor does it show how to handle a true custom component.

Keep in mind that in Vue button will render without fail, regardless of resolution, because it is a native HTML element, so the playground example doesn't illuminate anything because it does not feature an element that would fail to render if not resolved, etc.

Best I can tell, something outside the HTML spec needs to be shown in an example. Both one-off and consecutive, adjacent use of true custom components (non-HTML spec), including how to use a resolver, etc.

I submitted this issue after having read and absorbed all the provided docs beforehand, including the HomeView.vue example you referenced. That approach did not appear to work for true custom components.

Any help you can provide would be much appreciated, but please keep in mind that I provided a detailed example above that I would like addressed. If need be, I can create a reproduction. Just let me know.

konstantin-karlovich-unbiased-co-uk commented 4 weeks ago

@konstantin-karlovich-unbiased-co-uk Thank you for the feedback! However, I have already taken a look at that. To me, this is not an example of my use case nor does it show how to handle a true custom component.

Keep in mind that in Vue button will render without fail, regardless of resolution, because it is a native HTML element, so the playground example doesn't illuminate anything because it does not feature an element that would fail to render if not resolved, etc.

Best I can tell, something outside the HTML spec needs to be shown in an example. Both one-off and consecutive, adjacent use of true custom components (non-HTML spec), including how to use a resolver, etc.

I submitted this issue after having read and absorbed all the provided docs beforehand, including the HomeView.vue example you referenced. That approach did not appear to work for true custom components.

Any help you can provide would be much appreciated, but please keep in mind that I provided a detailed example above that I would like addressed. If need be, I can create a reproduction. Just let me know.

@babalugats76 Sorry, but, my comment was for @alvarosabu when he asked for an example when body has more than 1 entry