Skip to content

Commit

Permalink
feat: add renderer component (#3)
Browse files Browse the repository at this point in the history
* 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
remidej authored Nov 2, 2023
1 parent 2611c46 commit 8627a24
Show file tree
Hide file tree
Showing 13 changed files with 927 additions and 81 deletions.
9 changes: 8 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"root": true,
"extends": ["@strapi/eslint-config/front/typescript"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.eslint.json"]
},
Expand All @@ -12,7 +13,13 @@
}
},
"rules": {
"check-file/no-index": "off"
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
]
},
"overrides": []
}
2 changes: 2 additions & 0 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const config = {
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
setupFilesAfterEnv: ['@testing-library/jest-dom'],
};

// eslint-disable-next-line import/no-default-export
export default config;
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,14 @@
"@strapi/pack-up": "^4.14.4",
"@swc/core": "^1.3.93",
"@swc/jest": "^0.2.29",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.5",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"eslint": "^8.51.0",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-check-file": "^2.6.2",
Expand Down
54 changes: 54 additions & 0 deletions src/Block.tsx
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 };
231 changes: 231 additions & 0 deletions src/BlocksRenderer.tsx
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 };
9 changes: 0 additions & 9 deletions src/BlocksRenderer/BlocksRenderer.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/BlocksRenderer/index.ts

This file was deleted.

11 changes: 0 additions & 11 deletions src/BlocksRenderer/tests/BlocksRenderer.test.tsx

This file was deleted.

Loading

0 comments on commit 8627a24

Please sign in to comment.