Skip to content

Commit 8627a24

Browse files
authored
feat: add renderer component (#3)
* 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
1 parent 2611c46 commit 8627a24

13 files changed

+927
-81
lines changed

.eslintrc

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"root": true,
33
"extends": ["@strapi/eslint-config/front/typescript"],
4+
"parser": "@typescript-eslint/parser",
45
"parserOptions": {
56
"project": ["./tsconfig.eslint.json"]
67
},
@@ -12,7 +13,13 @@
1213
}
1314
},
1415
"rules": {
15-
"check-file/no-index": "off"
16+
"@typescript-eslint/consistent-type-imports": [
17+
"error",
18+
{
19+
"prefer": "type-imports",
20+
"fixStyle": "inline-type-imports"
21+
}
22+
]
1623
},
1724
"overrides": []
1825
}

jest.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const config = {
44
transform: {
55
'^.+\\.(t|j)sx?$': '@swc/jest',
66
},
7+
setupFilesAfterEnv: ['@testing-library/jest-dom'],
78
};
89

10+
// eslint-disable-next-line import/no-default-export
911
export default config;

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@
5757
"@strapi/pack-up": "^4.14.4",
5858
"@swc/core": "^1.3.93",
5959
"@swc/jest": "^0.2.29",
60+
"@testing-library/jest-dom": "^6.1.4",
6061
"@testing-library/react": "^14.0.0",
6162
"@types/jest": "^29.5.5",
6263
"@types/react": "^18.0.0",
6364
"@types/react-dom": "^18.0.0",
64-
"@typescript-eslint/eslint-plugin": "^6.8.0",
65-
"@typescript-eslint/parser": "^6.8.0",
66-
"eslint": "^8.51.0",
65+
"@typescript-eslint/eslint-plugin": "^6.9.0",
66+
"@typescript-eslint/parser": "^6.9.0",
67+
"eslint": "^8.52.0",
6768
"eslint-config-prettier": "^9.0.0",
6869
"eslint-import-resolver-typescript": "^3.6.1",
6970
"eslint-plugin-check-file": "^2.6.2",

src/Block.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as React from 'react';
2+
3+
import { useComponentsContext, type Node, type GetPropsFromNode } from './BlocksRenderer';
4+
import { Text } from './Text';
5+
6+
type BlockComponentProps = GetPropsFromNode<Node>;
7+
8+
interface BlockProps {
9+
content: Node;
10+
}
11+
12+
const voidTypes = ['image'];
13+
14+
const Block = ({ content }: BlockProps) => {
15+
const { children: childrenNodes, type, ...props } = content;
16+
17+
// Get matching React component from the context
18+
const { blocks, missingBlockTypes } = useComponentsContext();
19+
const BlockComponent = blocks[type] as React.ComponentType<BlockComponentProps> | undefined;
20+
21+
if (!BlockComponent) {
22+
// Only warn once per missing block
23+
if (!missingBlockTypes.includes(type)) {
24+
console.warn(`[@strapi/block-react-renderer] No component found for block type "${type}"`);
25+
missingBlockTypes.push(type);
26+
}
27+
28+
// Don't throw an error, just ignore the block
29+
return null;
30+
}
31+
32+
// Handle void types separately as they should not render children
33+
if (voidTypes.includes(type)) {
34+
return <BlockComponent {...props} />;
35+
}
36+
37+
return (
38+
<BlockComponent {...props}>
39+
{childrenNodes.map((childNode, index) => {
40+
if (childNode.type === 'text') {
41+
const { type: _type, ...childNodeProps } = childNode;
42+
43+
// TODO use node as key with WeakMap
44+
return <Text {...childNodeProps} key={index} />;
45+
}
46+
47+
// TODO use node as key with WeakMap
48+
return <Block content={childNode} key={index} />;
49+
})}
50+
</BlockComponent>
51+
);
52+
};
53+
54+
export { Block };

src/BlocksRenderer.tsx

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import * as React from 'react';
2+
3+
import { Block } from './Block';
4+
import { type Modifier, type TextInlineNode } from './Text';
5+
6+
/* -------------------------------------------------------------------------------------------------
7+
* TypeScript types and utils
8+
* -----------------------------------------------------------------------------------------------*/
9+
10+
interface LinkInlineNode {
11+
type: 'link';
12+
url: string;
13+
children: TextInlineNode[];
14+
}
15+
16+
interface ListItemInlineNode {
17+
type: 'list-item';
18+
children: DefaultInlineNode[];
19+
}
20+
21+
// Inline node types
22+
type DefaultInlineNode = TextInlineNode | LinkInlineNode;
23+
type NonTextInlineNode = Exclude<DefaultInlineNode, TextInlineNode> | ListItemInlineNode;
24+
25+
interface ParagraphBlockNode {
26+
type: 'paragraph';
27+
children: DefaultInlineNode[];
28+
}
29+
30+
interface QuoteBlockNode {
31+
type: 'quote';
32+
children: DefaultInlineNode[];
33+
}
34+
35+
interface CodeBlockNode {
36+
type: 'code';
37+
children: DefaultInlineNode[];
38+
}
39+
40+
interface HeadingBlockNode {
41+
type: 'heading';
42+
level: 1 | 2 | 3 | 4 | 5 | 6;
43+
children: DefaultInlineNode[];
44+
}
45+
46+
interface ListBlockNode {
47+
type: 'list';
48+
format: 'ordered' | 'unordered';
49+
children: (ListItemInlineNode | ListBlockNode)[];
50+
}
51+
52+
interface ImageBlockNode {
53+
type: 'image';
54+
image: {
55+
name: string;
56+
alternativeText?: string | null;
57+
url: string;
58+
caption?: string | null;
59+
width: number;
60+
height: number;
61+
formats?: Record<string, unknown>;
62+
hash: string;
63+
ext: string;
64+
mime: string;
65+
size: number;
66+
previewUrl?: string | null;
67+
provider: string;
68+
provider_metadata?: unknown | null;
69+
createdAt: string;
70+
updatedAt: string;
71+
};
72+
children: [{ type: 'text'; text: '' }];
73+
}
74+
75+
// Block node types
76+
type RootNode =
77+
| ParagraphBlockNode
78+
| QuoteBlockNode
79+
| CodeBlockNode
80+
| HeadingBlockNode
81+
| ListBlockNode
82+
| ImageBlockNode;
83+
type Node = RootNode | NonTextInlineNode;
84+
85+
// Util to convert a node to the props of the corresponding React component
86+
type GetPropsFromNode<T> = Omit<T, 'type' | 'children'> & { children?: React.ReactNode };
87+
88+
// Map of all block types to their matching React component
89+
type BlocksComponents = {
90+
[K in Node['type']]: React.ComponentType<
91+
// Find the BlockProps in the union that match the type key of the current BlockNode
92+
// and use it as the component props
93+
GetPropsFromNode<Extract<Node, { type: K }>>
94+
>;
95+
};
96+
97+
// Map of all inline types to their matching React component
98+
type ModifiersComponents = {
99+
[K in Modifier]: React.ComponentType<{ children: React.ReactNode }>;
100+
};
101+
102+
/* -------------------------------------------------------------------------------------------------
103+
* Default blocks and modifiers components
104+
* -----------------------------------------------------------------------------------------------*/
105+
106+
interface ComponentsContextValue {
107+
blocks: BlocksComponents;
108+
modifiers: ModifiersComponents;
109+
missingBlockTypes: string[];
110+
missingModifierTypes: string[];
111+
}
112+
113+
const defaultComponents: ComponentsContextValue = {
114+
blocks: {
115+
paragraph: (props) => <p>{props.children}</p>,
116+
quote: (props) => <blockquote>{props.children}</blockquote>,
117+
code: (props) => (
118+
<pre>
119+
<code>{props.children}</code>
120+
</pre>
121+
),
122+
heading: ({ level, children }) => {
123+
switch (level) {
124+
case 1:
125+
return <h1>{children}</h1>;
126+
case 2:
127+
return <h2>{children}</h2>;
128+
case 3:
129+
return <h3>{children}</h3>;
130+
case 4:
131+
return <h4>{children}</h4>;
132+
case 5:
133+
return <h5>{children}</h5>;
134+
case 6:
135+
return <h6>{children}</h6>;
136+
}
137+
},
138+
link: (props) => <a href={props.url}>{props.children}</a>,
139+
list: (props) => {
140+
if (props.format === 'ordered') {
141+
return <ol>{props.children}</ol>;
142+
}
143+
144+
return <ul>{props.children}</ul>;
145+
},
146+
'list-item': (props) => <li>{props.children}</li>,
147+
image: (props) => <img src={props.image.url} alt={props.image.alternativeText || undefined} />,
148+
},
149+
modifiers: {
150+
bold: (props) => <strong>{props.children}</strong>,
151+
italic: (props) => <em>{props.children}</em>,
152+
underline: (props) => <u>{props.children}</u>,
153+
strikethrough: (props) => <del>{props.children}</del>,
154+
code: (props) => <code>{props.children}</code>,
155+
},
156+
missingBlockTypes: [],
157+
missingModifierTypes: [],
158+
};
159+
160+
/* -------------------------------------------------------------------------------------------------
161+
* Context to pass blocks and inline components to the nested components
162+
* -----------------------------------------------------------------------------------------------*/
163+
164+
const ComponentsContext = React.createContext<ComponentsContextValue>(defaultComponents);
165+
166+
interface ComponentsProviderProps {
167+
children: React.ReactNode;
168+
value?: ComponentsContextValue;
169+
}
170+
171+
// Provide default value so we don't need to import defaultComponents in all tests
172+
const ComponentsProvider = ({ children, value = defaultComponents }: ComponentsProviderProps) => {
173+
const memoizedValue = React.useMemo(() => value, [value]);
174+
175+
return <ComponentsContext.Provider value={memoizedValue}>{children}</ComponentsContext.Provider>;
176+
};
177+
178+
function useComponentsContext() {
179+
return React.useContext(ComponentsContext);
180+
}
181+
182+
/* -------------------------------------------------------------------------------------------------
183+
* BlocksRenderer
184+
* -----------------------------------------------------------------------------------------------*/
185+
186+
interface BlocksRendererProps {
187+
content: RootNode[];
188+
blocks?: Partial<BlocksComponents>;
189+
modifiers?: Partial<ModifiersComponents>;
190+
}
191+
192+
const BlocksRenderer = (props: BlocksRendererProps) => {
193+
// Merge default blocks with the ones provided by the user
194+
const blocks = {
195+
...defaultComponents.blocks,
196+
...props.blocks,
197+
};
198+
199+
// Merge default modifiers with the ones provided by the user
200+
const modifiers = {
201+
...defaultComponents.modifiers,
202+
...props.modifiers,
203+
};
204+
205+
// Use refs because we can mutate them and avoid triggering re-renders
206+
const missingBlockTypes = React.useRef<string[]>([]);
207+
const missingModifierTypes = React.useRef<string[]>([]);
208+
209+
return (
210+
<ComponentsProvider
211+
value={{
212+
blocks,
213+
modifiers,
214+
missingBlockTypes: missingBlockTypes.current,
215+
missingModifierTypes: missingModifierTypes.current,
216+
}}
217+
>
218+
{/* TODO use WeakMap instead of index as the key */}
219+
{props.content.map((content, index) => (
220+
<Block content={content} key={index} />
221+
))}
222+
</ComponentsProvider>
223+
);
224+
};
225+
226+
/* -------------------------------------------------------------------------------------------------
227+
* Exports
228+
* -----------------------------------------------------------------------------------------------*/
229+
230+
export type { RootNode, Node, GetPropsFromNode };
231+
export { ComponentsProvider, useComponentsContext, BlocksRenderer };

src/BlocksRenderer/BlocksRenderer.tsx

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/BlocksRenderer/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/BlocksRenderer/tests/BlocksRenderer.test.tsx

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)