-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* setup renderer components and tests * Add lists support * Add quotes * Add image support * Fix image ts types * Improve tests * 1st round of feedback fixes Move exports to end of files Fix void nodes Move modifier type to Text file Remove wrapper div Rename type Rename block prop types * Flatten the tree * Warn once when component is missing * Only log in dev and test envs * Add code block * Prefix console warnings * Remove env check when warning * Make image children mandatory
- Loading branch information
Showing
13 changed files
with
927 additions
and
81 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import * as React from 'react'; | ||
|
||
import { useComponentsContext, type Node, type GetPropsFromNode } from './BlocksRenderer'; | ||
import { Text } from './Text'; | ||
|
||
type BlockComponentProps = GetPropsFromNode<Node>; | ||
|
||
interface BlockProps { | ||
content: Node; | ||
} | ||
|
||
const voidTypes = ['image']; | ||
|
||
const Block = ({ content }: BlockProps) => { | ||
const { children: childrenNodes, type, ...props } = content; | ||
|
||
// Get matching React component from the context | ||
const { blocks, missingBlockTypes } = useComponentsContext(); | ||
const BlockComponent = blocks[type] as React.ComponentType<BlockComponentProps> | undefined; | ||
|
||
if (!BlockComponent) { | ||
// Only warn once per missing block | ||
if (!missingBlockTypes.includes(type)) { | ||
console.warn(`[@strapi/block-react-renderer] No component found for block type "${type}"`); | ||
missingBlockTypes.push(type); | ||
} | ||
|
||
// Don't throw an error, just ignore the block | ||
return null; | ||
} | ||
|
||
// Handle void types separately as they should not render children | ||
if (voidTypes.includes(type)) { | ||
return <BlockComponent {...props} />; | ||
} | ||
|
||
return ( | ||
<BlockComponent {...props}> | ||
{childrenNodes.map((childNode, index) => { | ||
if (childNode.type === 'text') { | ||
const { type: _type, ...childNodeProps } = childNode; | ||
|
||
// TODO use node as key with WeakMap | ||
return <Text {...childNodeProps} key={index} />; | ||
} | ||
|
||
// TODO use node as key with WeakMap | ||
return <Block content={childNode} key={index} />; | ||
})} | ||
</BlockComponent> | ||
); | ||
}; | ||
|
||
export { Block }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,231 @@ | ||
import * as React from 'react'; | ||
|
||
import { Block } from './Block'; | ||
import { type Modifier, type TextInlineNode } from './Text'; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* TypeScript types and utils | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
interface LinkInlineNode { | ||
type: 'link'; | ||
url: string; | ||
children: TextInlineNode[]; | ||
} | ||
|
||
interface ListItemInlineNode { | ||
type: 'list-item'; | ||
children: DefaultInlineNode[]; | ||
} | ||
|
||
// Inline node types | ||
type DefaultInlineNode = TextInlineNode | LinkInlineNode; | ||
type NonTextInlineNode = Exclude<DefaultInlineNode, TextInlineNode> | ListItemInlineNode; | ||
|
||
interface ParagraphBlockNode { | ||
type: 'paragraph'; | ||
children: DefaultInlineNode[]; | ||
} | ||
|
||
interface QuoteBlockNode { | ||
type: 'quote'; | ||
children: DefaultInlineNode[]; | ||
} | ||
|
||
interface CodeBlockNode { | ||
type: 'code'; | ||
children: DefaultInlineNode[]; | ||
} | ||
|
||
interface HeadingBlockNode { | ||
type: 'heading'; | ||
level: 1 | 2 | 3 | 4 | 5 | 6; | ||
children: DefaultInlineNode[]; | ||
} | ||
|
||
interface ListBlockNode { | ||
type: 'list'; | ||
format: 'ordered' | 'unordered'; | ||
children: (ListItemInlineNode | ListBlockNode)[]; | ||
} | ||
|
||
interface ImageBlockNode { | ||
type: 'image'; | ||
image: { | ||
name: string; | ||
alternativeText?: string | null; | ||
url: string; | ||
caption?: string | null; | ||
width: number; | ||
height: number; | ||
formats?: Record<string, unknown>; | ||
hash: string; | ||
ext: string; | ||
mime: string; | ||
size: number; | ||
previewUrl?: string | null; | ||
provider: string; | ||
provider_metadata?: unknown | null; | ||
createdAt: string; | ||
updatedAt: string; | ||
}; | ||
children: [{ type: 'text'; text: '' }]; | ||
} | ||
|
||
// Block node types | ||
type RootNode = | ||
| ParagraphBlockNode | ||
| QuoteBlockNode | ||
| CodeBlockNode | ||
| HeadingBlockNode | ||
| ListBlockNode | ||
| ImageBlockNode; | ||
type Node = RootNode | NonTextInlineNode; | ||
|
||
// Util to convert a node to the props of the corresponding React component | ||
type GetPropsFromNode<T> = Omit<T, 'type' | 'children'> & { children?: React.ReactNode }; | ||
|
||
// Map of all block types to their matching React component | ||
type BlocksComponents = { | ||
[K in Node['type']]: React.ComponentType< | ||
// Find the BlockProps in the union that match the type key of the current BlockNode | ||
// and use it as the component props | ||
GetPropsFromNode<Extract<Node, { type: K }>> | ||
>; | ||
}; | ||
|
||
// Map of all inline types to their matching React component | ||
type ModifiersComponents = { | ||
[K in Modifier]: React.ComponentType<{ children: React.ReactNode }>; | ||
}; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* Default blocks and modifiers components | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
interface ComponentsContextValue { | ||
blocks: BlocksComponents; | ||
modifiers: ModifiersComponents; | ||
missingBlockTypes: string[]; | ||
missingModifierTypes: string[]; | ||
} | ||
|
||
const defaultComponents: ComponentsContextValue = { | ||
blocks: { | ||
paragraph: (props) => <p>{props.children}</p>, | ||
quote: (props) => <blockquote>{props.children}</blockquote>, | ||
code: (props) => ( | ||
<pre> | ||
<code>{props.children}</code> | ||
</pre> | ||
), | ||
heading: ({ level, children }) => { | ||
switch (level) { | ||
case 1: | ||
return <h1>{children}</h1>; | ||
case 2: | ||
return <h2>{children}</h2>; | ||
case 3: | ||
return <h3>{children}</h3>; | ||
case 4: | ||
return <h4>{children}</h4>; | ||
case 5: | ||
return <h5>{children}</h5>; | ||
case 6: | ||
return <h6>{children}</h6>; | ||
} | ||
}, | ||
link: (props) => <a href={props.url}>{props.children}</a>, | ||
list: (props) => { | ||
if (props.format === 'ordered') { | ||
return <ol>{props.children}</ol>; | ||
} | ||
|
||
return <ul>{props.children}</ul>; | ||
}, | ||
'list-item': (props) => <li>{props.children}</li>, | ||
image: (props) => <img src={props.image.url} alt={props.image.alternativeText || undefined} />, | ||
}, | ||
modifiers: { | ||
bold: (props) => <strong>{props.children}</strong>, | ||
italic: (props) => <em>{props.children}</em>, | ||
underline: (props) => <u>{props.children}</u>, | ||
strikethrough: (props) => <del>{props.children}</del>, | ||
code: (props) => <code>{props.children}</code>, | ||
}, | ||
missingBlockTypes: [], | ||
missingModifierTypes: [], | ||
}; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* Context to pass blocks and inline components to the nested components | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
const ComponentsContext = React.createContext<ComponentsContextValue>(defaultComponents); | ||
|
||
interface ComponentsProviderProps { | ||
children: React.ReactNode; | ||
value?: ComponentsContextValue; | ||
} | ||
|
||
// Provide default value so we don't need to import defaultComponents in all tests | ||
const ComponentsProvider = ({ children, value = defaultComponents }: ComponentsProviderProps) => { | ||
const memoizedValue = React.useMemo(() => value, [value]); | ||
|
||
return <ComponentsContext.Provider value={memoizedValue}>{children}</ComponentsContext.Provider>; | ||
}; | ||
|
||
function useComponentsContext() { | ||
return React.useContext(ComponentsContext); | ||
} | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* BlocksRenderer | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
interface BlocksRendererProps { | ||
content: RootNode[]; | ||
blocks?: Partial<BlocksComponents>; | ||
modifiers?: Partial<ModifiersComponents>; | ||
} | ||
|
||
const BlocksRenderer = (props: BlocksRendererProps) => { | ||
// Merge default blocks with the ones provided by the user | ||
const blocks = { | ||
...defaultComponents.blocks, | ||
...props.blocks, | ||
}; | ||
|
||
// Merge default modifiers with the ones provided by the user | ||
const modifiers = { | ||
...defaultComponents.modifiers, | ||
...props.modifiers, | ||
}; | ||
|
||
// Use refs because we can mutate them and avoid triggering re-renders | ||
const missingBlockTypes = React.useRef<string[]>([]); | ||
const missingModifierTypes = React.useRef<string[]>([]); | ||
|
||
return ( | ||
<ComponentsProvider | ||
value={{ | ||
blocks, | ||
modifiers, | ||
missingBlockTypes: missingBlockTypes.current, | ||
missingModifierTypes: missingModifierTypes.current, | ||
}} | ||
> | ||
{/* TODO use WeakMap instead of index as the key */} | ||
{props.content.map((content, index) => ( | ||
<Block content={content} key={index} /> | ||
))} | ||
</ComponentsProvider> | ||
); | ||
}; | ||
|
||
/* ------------------------------------------------------------------------------------------------- | ||
* Exports | ||
* -----------------------------------------------------------------------------------------------*/ | ||
|
||
export type { RootNode, Node, GetPropsFromNode }; | ||
export { ComponentsProvider, useComponentsContext, BlocksRenderer }; |
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.