@zuze/react-ast
v0.1.0
Published
Render React from JSON
Downloads
6
Maintainers
Readme
@zuze/react-ast: For creating highly configurable react applications
Check out the 📕 official documentation 📕
What's this?
A pretty straightforward recursive renderer with a tiny amount of syntactic sugar (using $cmp
and interpolation!) to compile building block components into complete react applications. Used to develop low-code application frameworks OR to quickly deploy to "hotspots" in existing applications where you need maximum configurability.
Why?
Building high quality, highly reusable components is the best approach to react (or any other component-based UI framework).
@zuze/react-ast
was built with the goal of having non-technical users build multiple, complete applications using low code concepts.
The rest of this README is geared towards technical users and how to implement @zuze/react-ast
. After implementing @zuze/react-ast
, you'll need to create your own documentation (hopefully we can help with that too!) about what components are available for your non-technical users to configure and how they can be configured.
Install
# npm
npm install @zuze/react-ast
# yarn
yarn add @zuze/react-ast
Usage
Simple Example
import React from 'react';
import ReactDOM from 'react-dom';
import ReactAST from '@zuze/react-ast'
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'h1',
children: ['Hello World']
}
}}
/>
);
ReactDOM.render(<App/>, document.getElementById('root'));
Nesting Components
Creating components dynamically is done with the special key $cmp
in your JSON (configurable via the cmpKey
prop).
import React from 'react';
import ReactDOM from 'react-dom';
import ReactAST from '@zuze/react-ast'
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'h1',
children: [
'Please click on this ',
{
$cmp: {
component: 'a'
props: {
href: 'https://www.google.com'
},
children:['link to Google']
}
}
]
}
}}
/>
);
ReactDOM.render(<App/>, document.getElementById('root'));
// Output <h1>Please click on this <a href="https://www.google.com">link to google</a></h1>
Props
components: { [key: string]: ComponentDescriptor }
- A map ofComponentDescriptors
root: = 'MAIN'
- The key of theComponentDescriptor
to start renderingresolver: ComponentResolver
- Given aComponentDescriptor
, return a ComponentComponent?: ReactElement
- Render a descriptor using a Componentrender?: Renderer
- Render a descriptor using a functionchildren?: Renderer
- Alternate way to render a descriptor using a function
Advanced Props
cmpKey = '$cmp'
- Object key the will yield dynamic components.merge?: (...ComponentDescriptors) => ComponentDescriptor
- merge function used to combineComponentDescriptors
when using interpolated$cmp
's - see merging.interpolate: RegExp = /\{\{(.+?)\}\}/g
- Pattern which will be used to interpolate variablescontext: {[key: string]: any} = {}
- Context that can be used for interpolation patterns - e.g.{{some_string}}
contextKey: string = $
- When interpolating, do it fromcontext
- e.g.{{$string_from_context}}
Interpolation and Context
Interpolation and context is at the very core of @zuze/react-ast
. Simply, it works like this:
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div',
children: [ 'Hi {{$user}}!' ]
}
}}
context={{
user:'joe'
}}
/>
);
// outputs <div>Hi joe!</div>
Interpolation is not just for strings. You can interpolate functions:
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div',
props: {
onClick: '{{$showAlert}}'
},
children: [ 'Hi {{$user}}!' ]
}
}}
context={{
user: 'joe'
showAlert: () => {
alert('clicked');
}
}}
/>
);
// outputs <div>Hi joe!</div>
... arrays:
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div',
children: '{{$friends}}'
}
}}
context={{
friends: ['joe','bill','sam']
}}
/>
);
// outputs <div>joebillsam</div>
...full objects:
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div',
children: [
'Hi {{$user}}!',
{
$cmp: '{{ANOTHER_DIV}}'
}
]
},
ANOTHER_DIV: {
component: 'div',
children: ['Im another div']
}
}}
/>
);
// outputs <div>Hi joe! <div>Im another div</div></div>
... which can be used to create composable dynamic components (🎉🎉🎉):
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div',
children: [
{
$cmp: '{{TITLE_CMP}}',
children: ['Hi {{$user}}!']
}
]
}
TITLE_CMP: {
component: 'h1',
props: {
title: 'Im a heading'
}
}
}}
context={{
user: 'joe'
}}
/>
);
// outputs <div><h1 title="Im a heading">Hi joe!</h1></div>
Merging
When combining component descriptors in the way detailed above, the merge
prop is used. By default, component descriptors are just shallow merged with the exception of the props
key. props
will be merged one level deep:
// example
const App = () => (
<ReactAST
components={{
MAIN: {
component: 'div'
children: [
{
$cmp: "SECOND",
props: {
propA: 'New A',
propB: 'New B',
}
},
{
$cmp: "SECOND",
props: {
propD: 'New D',
},
children: ['Child 1','Child 2']
}
]
},
SECOND: {
component: 'span',
props: {
propA: 'Original A',
propD: 'Original D',
},
children: ['Original Child']
}
}}
/>
);
/*
<div>
<span propA="New A" propB="New B" propD="Original D">Original Child</span>
<span propA="OriginalA" propD="New D">Child1Child2</span>
</div>
*/
Types
ComponentMap
A ComponentMap is a key-value map of ComponentDescriptor
s. The string keys
are referred to as ComponentIdentifier
s
{
[key: string]: ComponentDescriptor
}
// example
<ReactAST
components={{
MAIN: {
component:'div',
children:[
{
$cmp: '{{OTHER_COMPONENT}}}'
}
]
},
OTHER_COMPONENT: {
component: 'span',
children: [ 'Hello World!' ]
}
}}
/>
// output
// <span>Hello World!</span>
ComponentDescriptor
Only props
and children
are treated as special keys in a ComponentDescriptor
. It is encouraged to use component
and module
, but as long as your resolver
can return a component given a ComponentDescriptor
, then naming isn't critical.
{
component: string,
props?: { [key: string]: any },
children?: any[],
[key: string]: any
}
// example of a host component (no module)
<ReactAST
components={{
MAIN: {
component: 'span',
props: {
title: 'My Span Title'
},
children: [ 'Hello world!' ]
}
}}
/>
// output
// <span title="My Span Title">Hello World!</span>
DynamicComponents
A DynamicComponent is simply a resolved ComponentDescriptor
to a React Component. They are created using the $cmp
keyword (configurable using the cmpKey
prop).
{
$cmp: ComponentDescriptor
}
<ReactAST
components={{
MAIN: {
component: 'div',
children: [
{
$cmp: {
component: 'span',
props: {
title: 'My Span Title',
},
children: [ 'Hello World!' ]
}
}
]
}
}}
/>
// Output
// <div><span title="My Span Title">Hello World</span></div>
ComponentResolver
So far, we've just been rendering HTML, pretty useless stuff. Let's supercharge our capabilities with a ComponentResolver
ComponentResolver: (descriptor: ComponentDescriptor) => ReactElement
Very simple, a ComponentResolver
is passed the ComponentDescriptor
and returns a React component.
// type
// (descriptor: ComponentDescriptor) => ReactElement;
// example using a module property on the component descriptor
import * as mui from `@material-ui/core`;
const resolver = ({component}) => {
if(component === 'MuiButton') return mui.Button;
throw new Error(`Component ${component} could not be found!`);
}
<ReactAST
resolver={resolver}
components={{
MAIN: {
component: 'MuiButton',
props: {
variant: 'contained'
},
children: [ 'Click Me!' ]
}
}}
/>
A highly useful pattern is to specify a module
property on your component descriptor:
// example using a module property on the component descriptor
import * as mui from `@material-ui/core`;
const resolver = ({module,component}) => {
if(module !== 'mui') throw new Error(`Component ${component} could not be found!`);
return mui[component];
}
<ReactAST
resolver={resolver}
components={{
MAIN: {
component: 'Button',
module:'mui',
props: {
variant: 'contained'
},
children: [ 'Click Me!' ]
}
}}
/>
Code Splitting with Module Resolvers
To set up your create-react-app for code splitting, all you need to do is put a jsconfig.json
file in your base directory and put this in it:
{
"baseUrl": "src"
}
Now you can use a dynamic import in your resolver:
import { createImporter, createComponentResolver } from '@zuze/react-ast';
const importer = createImporter(({ component }) => import(`./components/${component}`));
// the `resolver` prop
const resolver = createComponentResolver(importer);
NOTE: In order to tell webpack what bundles need to be generated, it's critical that the dynamic import have a fixed prefix - hence ./components/${component}
. If we just did import(component)
- webpack would not be able to determine what bundles need to be generated.
NOTE 2: It is necessary to wrap your resolver in createComponentResolver
when dynamically importing components. find out why
Renderer
({ render: (props = {}) => any, descriptor: ComponentDescriptor, key: string }) => any
A Renderer
- given as either a Component
or the render
or children
prop - is used when extra processing that can't be encoded in the AST may need to be happen. For those familiar with material-ui, an example might be a theme
:
import * as mui from `@material-ui/core`;
const resolver = ({module,component}) => {
if(module !== 'mui')
throw new Error('Can only use components exported by material-ui');
return mui[component];
}
const { ThemeProvider } = mui;
<ReactAST
components={{
MAIN: {
component: 'Button',
module: 'mui',
props: {
variant: 'contained',
},
children: [ 'Hello World MUI Button!' ],
theme: {
colors: {
primary: 'red'
}
}
}
}}
render={({
render,
descriptor,
key
}) => <ThemeProvider key={key} theme={descriptor.theme}>{render()}</ThemeProvider>}
/>
NOTE: If you are wrapping the output of the render function in another component, like above, you'll need to pass in the key
parameter to avoid warnings in development about not supplying keys.
You can additionally pass props to the render
function that will end up in the component described in the AST:
<ReactAST
components={{
MAIN: {
component: 'Button',
module: 'mui',
props: {
variant: 'contained',
},
children: [ 'Hello World MUI Button!' ]
}
}}
render={({render}) => render({onPress:() => alert('Pressed')})}
/>
As a Component
(example using material-ui/styles solution):
import { makeStyles } from '@material-ui/styles';
const UseStylesCmp = ({useStyles,children,...rest}) => children({
...rest,
classes:useStyles()
})
const MyRendererComponent = ({key,render,descriptor}) => {
// when using a Component as a renderer, you can use hooks/state/context!
const someProps = useSomeCustomHook(descriptor);
return (
<UseStylesCmp
{...someProps}
useStyles={makeStyles(descriptor.styles)}
>{render}</UseStyles>
}
<ReactAST
components={{
MAIN: {
component: 'Button',
module: 'mui',
props: {
variant: 'contained',
},
styles: {
root: {
background: 'blue'
}
},
children: [ 'Hello World MUI Button!' ]
}
}}
Component={MyRendererComponent}
/>
createComponentResolver
It's critical that the same component is always returned from the resolver
given the same descriptor. Otherwise, components will be unmounted and state will be lost. createComponentResolver
maintains an internal cache
that can be configured by supplying the second parameter to createComponentResolver
createComponentResolver(resolver: Resolver, cacheFn: (descriptor) => string = ({component}) => component)
The cacheFn
function accepts a ComponentDescriptor
and must return a string
. By default it uses the component
field.
It's only necessary to use this function if you are dynamically importing components using createImporter
because the importer creates a React.lazy
component. This same React.lazy
component instance must always be returned.
createImporter
(and React.lazy
) is just one way to utilize code-splitting, but it's not the only way. We utilize code splitting in our LazyAST and Snippet components for our documentation site without using React.lazy
License
MIT © akmjenkins