theisel / astro-portabletext

Render Portable Text with Astro
https://www.npmjs.com/package/astro-portabletext
ISC License
64 stars 0 forks source link

Cannot pass props from Astro to an internal element #155

Closed milewskibogumil closed 2 months ago

milewskibogumil commented 2 months ago

Hello there!

I'm creating a reusable Heading element with Portable Text. The main functionality is to return just h1-h6 tag, with content inside, or <span> inside the main element, if there will be an enter inside a Portable Text.

Example:

Whole page looks like:

---
const { portableTextContent } = await someQuery();
---
<Heading level="h2" value={portableTextContent} />

<style lang="scss">
  h2 {
    color: red;
    span {
      color: blue;
    }
  }
</style>

My Heading.astro component looks like:

---
import type { HTMLAttributes } from 'astro/types';
import { PortableText } from 'astro-portabletext';
import type { PortableTextBlock } from '@portabletext/types';
import Block from './Block.astro';

type Props = {
  level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  value: PortableTextBlock;
} & HTMLAttributes<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>;

const { level, value, ...props } = Astro.props;

const HeadingTag = level;
---
<HeadingTag {...props}>
  <PortableText value={value} components={{ block: Block }} />
</HeadingTag>

And Block.astro:

---
import type { Props as $, Block as BlockType } from 'astro-portabletext/types';

type Props = $<BlockType>;

const props = Astro.props;

const isFirstElement = props.index === 0;
---
{
  isFirstElement ? (
    <slot {...props} />
  ) : (
    <span {...props}>
      <slot />
    </span>
  )
}

The problem is that I cannot pass a {...props} from <Heading>, to <span>, and that means that scoped SCSS is not working.

Any ideas how to solve that?

theisel commented 2 months ago

Hi @milewskibogumil I've had a play on Stackblitz astro-portabletext issue #155 and there is a quirk with Astro.

Have a look at the <Heading /> component and you'll notice a child <span> . With it, the styling works and without it the styling is lost.

The issue lies within the page. You need to wrap the <Heading /> component with a <Layout /> component to make it stick.

I would however, approach rendering Portable Text content, in a different manner as <PortableText /> shouldn't be nested within a heading.

Extend astro-portabletext Block component

/* BlockExt.astro */
---
import type { Props as $, Block as BlockType } from "astro-portabletext/types";
import { Block } from "astro-portabletext/components";
import Heading from "./Heading.astro"; // 👈 custom heading

export type Props = $<BlockType>;

const props = Astro.props;
// const styleIs = (style: string) => style === props.node.style;
const isHeading = /^h[1-6]$/.test(props.node.style);

const Cmp = isHeading ? (
  Heading // custom heading
) : (
  Block  // fallback to `astro-portabletext`
)
---

<Cmp {...props}><slot /></Cmp>
/* Heading.astro */
---
import type { HTMLAttributes } from 'astro/types';
import type { Props as $, Block as BlockType } from "astro-portabletext/types";

type Props = $<Block>;

const { node } = Astro.props;

const HeadingTag = node.style;
---

<HeadingTag>
  <slot />
</HeadingTag>

<style lang="scss">
  h2 {
    color: red;

    span {
      color: blue;
    }
  }
</style>
/* some page */ 
---
import { PortableText } from "astro-portabletext";
import Layout from "../layouts/Layout.astro";
import BlockExt from "path/to/BlockExt.astro";

const { portableTextContent } = await someQuery();
---

<Layout>
  <PortableText value={portableTextContent} components={{ block: BlockExt }} />
</Layout>

You can go a step further by extending PortableText component.

I hope this helps, let me know your thoughts.

milewskibogumil commented 2 months ago

Tom, thanks for your suggestions!

Initially I get only the normal Portable Text, not styled as Heading from my CMS. Then I do all the transformation (converting the simple <p> to initial <h?>.

The final code, which works as I intended, looks like this:

/* page.astro */
---
const { portableTextContent } = await someQuery();
---
<Heading level="h2" value={portableTextContent} />

<style lang="scss">
  h2 {
    color: red;
    span {
      color: blue;
    }
  }
</style>
/* Heading.astro */
---
import { PortableText } from 'astro-portabletext';
import Block from './Block.astro';
import type { PortableTextBlock } from '@portabletext/types';
import type { HTMLAttributes } from 'astro/types';

type Props = {
  level: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  value: PortableTextBlock[];
} & HTMLAttributes<'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'>;

const { level, value, ...props } = Astro.props;

const HeadingTag = level;
---
<HeadingTag {...props}>
  {
    value.map((block: PortableTextBlock, index: number) =>
      index > 0 ? (
        <span {...props}>
          <PortableText value={block} components={{ block: Block }} />
        </span>
      ) : (
        <PortableText value={block} components={{ block: Block }} />
      ),
    )
  }
</HeadingTag>
/* Block.astro */
<slot/>

And I have concerns only about Block.astro, but in Astro it is probably impossible to do it any other way? In React it would be enough to use something just like: components={{ block: ({ children }) => children }}

theisel commented 2 months ago

I'll have to get back to you on this. The ideal is to use usePortableText however, this is not working as expected. I'll have to do some checking.

milewskibogumil commented 2 months ago

Okay, thank you @theisel! Waiting for more information about that.

theisel commented 2 months ago

Hi @milewskibogumil the issue lies in how the Portable Text is presented.

You have mentioned

Initially I get only the normal Portable Text, not styled as Heading from my CMS. Then I do all the transformation (converting the simple <p> to initial <h?>.

Out of interest which CMS are you using? If you are using Sanity, I would look into implementing decorators or annotations.

astro-portabletext is simple, it hands over data to relevant components.

As a guide, have a look at the following code. I have removed all Typescript stuff to make it easier to read. You will notice that the span with text "One" has a mark.

Let me know how things go.

// pages/*.astro
---
import { PortableText } from "astro-portabletext";
import Layout from "../layouts/Layout.astro";
import BlockExt from "../components/BlockExt.astro";
import MarkExt from "../components/MarkExt.astro";

const value = [
  {
    "_type": "block",
    "style": "h2",
    "children": [
      {
        "_type": "span",
        "text": "Heading "
      },
      {
        "_type": "span",
        "text": "One",
        "marks": ["abc123"], // 👈 this is where the magic happens
      },
    ],
    "markDefs": [
      {
        "_type": "highlight",
        "_key": "abc123"
      }
    ]
  },
];

const components = { block: BlockExt, mark: MarkExt };
---

<Layout>
  <PortableText {value} {components} />
</Layout>
// BlockExt.astro
---
import { Block } from "astro-portabletext/components";
import Heading from "./Heading.astro"; // 👈 custom heading

const props = Astro.props;
const isHeading = /^h[1-6]$/.test(props.node.style);

const Cmp = isHeading ? (
  Heading // custom heading
) : (
  Block  // fallback to `astro-portabletext`
)
---

<Cmp {...props}><slot /></Cmp>
// Heading.astro
---
const { node, index, isInline, ...rest } = Astro.props;
const HeadingTag = node.style;
---

<HeadingTag class="heading" {...rest}><slot /></HeadingTag>

<style>
  .heading {
    color: red;
  }
</style>
// MarkExt.astro
---
import { Mark } from "astro-portabletext/components";
import Highlight from "./Highlight.astro"; // 👈 custom mark

const props = Astro.props;
const { node } = props;
const markTypeIs = (markType: string) => markType === node.markType;

const Cmp = markTypeIs('highlight') ? Highlight : Mark;
---

<Cmp {...props}><slot /></Cmp>
// Hightlight.astro
---
/*
 You may need to do the following just in case of whitespace

 const html = await Astro.slots.render('default').then((str) => str.trim(());

 <span>{html}</span>
*/
---
<span><slot /></span>

<style>
  span {
    background: green;
    color: white;
    padding-inline: 0.25rem;
    margin-inline: 0.25rem;
  }
</style>
theisel commented 2 months ago

Closing this issue as there hasn't been any recent activity. If you have any further questions or updates, feel free to reopen or create a new issue.