Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Component Resolver (Vue) #115

Open
babalugats76 opened this issue Oct 11, 2024 · 7 comments
Open

Custom Component Resolver (Vue) #115

babalugats76 opened this issue Oct 11, 2024 · 7 comments
Assignees
Labels
question Further information is requested

Comments

@babalugats76
Copy link

babalugats76 commented Oct 11, 2024

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 alvarosabu added the question Further information is requested label Oct 17, 2024
@alvarosabu alvarosabu self-assigned this Oct 17, 2024
@alvarosabu
Copy link
Contributor

alvarosabu commented Oct 17, 2024

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
Copy link
Author

babalugats76 commented Oct 17, 2024

@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
Copy link
Contributor

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
Copy link
Author

@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

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
Copy link
Author

babalugats76 commented Oct 22, 2024

@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

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants