- Notifications
You must be signed in to change notification settings - Fork2
Alethio Content Management System
License
Alethio/cms
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
Alethio CMS is a front-end content management system designed for real-time and frequently updating data. It features a plugin-based architecture, supporting independent content modules that can be configured and loaded at runtime.
- Features
- Concepts
- Getting started
- Plugin tutorial
- Prerequisites
- Writing a simple module
- Adding real data and module context
- Reusing data adapters
- State transitions and optional adapters
- Page critical modules
- Reacting to data changes
- Reacting to MobX observable changes
- Adding plugin configuration
- Configuration per module/page
- Plugin localization and translation strings
- Plugin runtime API
- TypeScript support
- Linking between internal pages
- Creating dynamic contexts
- Data adapter dependencies
- Inline modules
- Data Sources
- Data dependencies between plugins
- Inline plugins
- Deployment with plugins
- Help System
- CMS Development
- Dynamic content loading (both code / markup and data) with loading state management
- Plugins can be baked in at build-time / deployment or dynamically loaded at runtime from an external repository
- React/MobX-based rendering stack
- Customizable pages, routes and internal linking
- Supports plugins with multiple locales/languages
A plugin is a separate JavaScript bundle, that is dynamically loaded by the CMS at runtime (with JSONP). It can publicly export a collection of entities (pages, modules etc.; detailed below) usable by other plugins or by the CMS itself. The user can then reference them in the configuration file of the CMS to form complex content structures.
Each exported entity type is assigned a public URI, by which it is uniquely identified outside the plugin or even within the plugin itself. A custom URI schema is defined for each entity type:
e.g.page://aleth.io/explorer/block
ormodule://aleth.io/explorer/block/details
As it can be seen above, each URI has a differentiating domain name and path, which is specific to each plugin publisher. It is strongly recommended to use the following guidelines when name-spacing your entities:
- the domain component should be the publisher's tld
- the path component should convey what the entity does, as simply as possible, while reducing the chance of naming collisions
This will make it a lot easier for other people to configure the CMS and understand what does what at a glance.
A module is the basic building block of the CMS, which defines how data is rendered. At its core, the module is nothing more than a wrapper over a React component that contains HTML. It doesn't handle data fetching or domain logic, but acts more as a dumb template (data is spoon-fed into it by the CMS).
A page definition represents content that can be seen at a specified URL / route inside the application. It contains routing information (react-router is used in the background) and an HTML template with static content into which modules can be inserted dynamically.
A slot is a named placeholder that is placed inside a React component, in either a module or a page, allowing hierarchical (tree) structures of modules to be created dynamically at runtime. The module that contains the placeholder has no knowledge of the content that is being inserted in it, but simply renders whatever content the CMS decides to place inside that slot.
Example:
The following diagram shows how the above elements can be combined to form a complete page, the Alethio Explorer account page.
Note: This hierarchy is also reflected in the React component render tree and the CMS configuration.
A data source is a wrapper over an external data provider (e.g. web3 or a server API). The purpose of this abstraction is to provide low-level access to data primitives, which can then be shared with other plugins.
A data adapter aggregates data from one or more data sources. It sits as a middleman between the data source and the content module that displays the aggregated data. It defines how the data is loaded and when to trigger a refresh of that data.
Context can be thought of as an indirect means of parametrising modules, so that we could reuse them into different pages or areas of a page at runtime, without altering their original implementation. More specifically, it represents the pathing / routing information required to render a module. It is the set of query parameters that a data adapter uses to retrieve its data and thus enabling dynamic content modules to be rendered. Context can either be specified through URL route params (root context) or derived from another context, through a pure transformation function. (e.g.context2 = f(rootContext, otherData)
)
Example:
The following diagram show the data flow in the Alethio Explorer account page.
Notes:
- Each module declares a list of data adapters (by URI) that it requires. Based on this information, the CMS internally creates a data loader which queries the data adapters and feeds the results to each module. As it can be seen, a single data loader instance is created per context as an optimization, instead of a separate instance for each module.
- A nested context is created so we can insert modules that are tailored for contracts. This is done through a pure function that receives the parent context and data from the account details adapter as an input and produces a new context with the contractId as output.
The next diagram shows how a module is rendered based on data that it depends on.
Notes:
- The module definitions contains references to some data adapters, delegating the responsibility of fetching the required data, and also a react component that can be loaded asynchronously
- The CMS will re-render and update the module content every time the data/component loading state changes
You will need a local installation of the CMS. You can start with your favorite React boilerplate and use the CMS component directly in your root component, as described in the next section. Or, you can have a look at theAlethio Lite Explorer for an example setup (more specifically,App.tsx ).
With an existing app already set up, you will need to render theCms
component into the root React component of your app:
importReactfrom"react";import{Cms}from"@alethio/cms";import{createTheme}from"@alethio/ui/lib/theme/createTheme";import{createPalette}from"@alethio/ui/lib/theme/createPalette";exportclassAppextendsReact.Component{constructor(props){super(props);this.theme=createTheme(createPalette());}render(){return<Cms// Ideally, the config should sit in a separate json file, which we load at runtimeconfig={{// We'll load the plugins from the same base URL as the app"pluginsBaseUrl":"/plugins","plugins":[],"pages":[],"rootModules":{// Named slot that we use in our root component to insert global modules"toolbar":[]}}}theme={this.theme}logger={console}locale="en-US"defaultLocale="en-US"renderErrorPage={()=><div>404</div>}renderErrorPlaceholder={()=><div>An error has occurred</div>}renderLoadingPlaceholder={()=><div>Loading...</div>}>{({ slots, routes})=>{return<div>{/* Route-independent content */}<div>{slots&&slots["toolbar"]}</div>{/* Route-dependent content */}<div>{routes}</div></div>;}}</Cms>;}}
SeeCms.tsx for props documentation.
Dynamic runtime configuration can be passed to theCms
instance via theconfig
prop. We recommend placing this configuration in a separateconfig.json
file, loaded from the app's base URL at runtime. This allows decoupling the configuration from the packaged source code and avoid a rebuild whenever the config is changed. In the future, we intent to provide a GUI-based configuration editor that avoids editing the raw JSON config altogether.
For full reference on the structure of the config object, please seeIConfigData.
For examples, see theplugin tutorial.
You can start by creating the plugin boilerplate with the CMS plugin tool:
$ npm i -g @alethio/cms-plugin-tool@^1.0.0-beta.4
Create a new empty folder where the plugin sources will be generated (this can be a git repo checkout):
$ mkdir <pluginName> && cd <pluginName>
IMPORTANT: You must be in the plugin folder to run the next command.
$ acp init <publisher> <pluginName> [npm_package_name]
Or, if you prefer JavaScript instead of TypeScript:
$ acp init --js <publisher> <pluginName> [npm_package_name]
<publisher>
is a namespace specific to the publisher of the plugin. We recommend setting this to your org's domain name, if you have one, or your github user handle, otherwise.<pluginName>
, together with the<publisher>
, is the name under which the CMS will reference the plugin. The CMS forms an unique plugin URI, likeplugin://<publisher>/<pluginName>
.[npm_package_name]
is the name that will be used in the generatedpackage.json
(used if the plugin will be published to npm).
Next, install the plugin dependencies and build the distributables with:
$ npm install
$ npm run watch
for development or$ npm run build
for minified/production build.
Switch back to your main app folder and link the plugin for development:
$ acp link <plugin_repo_folder>
This command will symlink the plugin distributable to thedist/plugins
folder for development. You can customize this folder with the--target
option. Callacp
with no arguments for detailed usage.
Finally, edit the CMS config object to enable the plugin (or the config.dev.jsonc file if using the lite explorer setup):
{// ..."plugins": [{"uri":"plugin://<publisher>/<pluginName>" }]// ...}
This section assumes that you already have a host app set up with a properly configured CMS instance and you have a freshly generated plugin boilerplate. If you haven't done so, please refer to the previous sections.
NOTE 1: We used$ acp init --js my.company.tld my-plugin @company/my-plugin
for our examples.
NOTE 2: For the sake of keeping things short and simple, all our examples are written in plain JavaScript (ES2015+). However, we do recommend switching to TypeScript later, for production. This will help catching errors and malformed plugin entities at compile-time and avoid cryptic runtime errors. See theTypeScript support section on how to properly type plugins.
We'll start with an example, which we'll use throughout the tutorial. Suppose we want to create a component that shows information about a user's profile. We'll create a new page with a single module that shows just the user's name.
Let's create a new module definition. For testing, we can add this directly in our plugin's entry file (make sure it has a proper jsx/tsx file extension), but later we will want to create a separate file for it and use an import instead.
importReactfrom"react";// in plugin's init method:api.addModuleDef("module://my.company.tld/my-plugin/profile",{// We don't use context for now, so it's an empty objectcontextType:{},// We don't use data adapters yet, we'll pass an empty arraydataAdapters:[],// The React component is loaded asynchronously and then cached by the CMS until a full page reload// It can either be a component class or a functional componentgetContentComponent:async()=>(props)=><div>Hello,{props.name}!</div>,// We receive some props from the cms and we map them to the props that our component needsgetContentProps:cmsProps=>({name:"Joe"})});
Right now, our module is not visible anywhere so let's also create a simple page for it:
// in plugin's init method:api.addPageDef("page://my.company.tld/my-plugin/profile-page",{contextType:{},paths:{// We'll map the page to an application path. The left-hand side is a react-router path expression.// The right hand side receives params extracted from the path expression and// resolves to an empty context object, which we won't use for now"/profile":params=>({})},// Function that resolves to a React component class/ functional component. It can also be async.getPageTemplate:()=>({ slots})=><div><h1>User profile</h1>{/* We'll define a single named slot ("content"), where the CMS will inject our module */}{slots&&slots.content}</div>});
Finally, let's tie the pieces together by editing the configuration again:
{// ..."pages": [{"def":"page://my.company.tld/my-plugin/profile-page","children": {"content": [ {"def":"module://my.company.tld/my-plugin/profile" } ] } }]// ...}
If we navigate to the/profile
path in our app, we should see everything correctly rendered. In the developer console you should see:
Loading plugin plugin://my.company.tld/my-plugin...Plugins loaded.
We currently have a module that shows dummy data. We'd like to be able to fetch that data from an external service. Let's assume our service fetches a user profile based on a user ID. This ID will effectively become thecontext of the profile page. We'll start by creating a data adapter for the profile data and then see how we can pass the context information through the page and to our module.
// ...// in plugin's init methodapi.addModuleDef("module://my.company.tld/my-plugin/profile",{// We declare that our module needs to be inserted into a context that has a userIdcontextType:{userId:"number"},// We can define data adapters inline, or reference global ones by their URI// For this example we'll just define the adapter inlinedataAdapters:[{// The alias is required so we can reference the result later in getContentProps.alias:"profile",// This is the actual adapter definitiondef:{contextType:{userId:"number"},asyncload(context,cancelToken){// This can be an XHRreturnPromise.resolve({name:"Joe",id:context.userId});}}}],// Here we define our React component inline. As best practice, we should move it to a separate file and// use a dynamic import to extract it into a separate bundle. Otherwise, we'll slow down the initial// page load while the CMS loads our plugingetContentComponent:async()=>(props)=><div> Hello,{props.name}! Your user ID is{props.id}.</div>,getContentProps:({ context, asyncData})=>({id:context.userId,// AsyncData is a wrapper over a data adapter result that also contains loading state info.// We get access to the wrapped data through the `data` propertyname:asyncData.get("profile").data.name})});
Now that we have our updated module, we need to make the context information available to it, otherwise it will no longer work since we require a userId to exist. We'll modify the profile page definition to accept a userId parameter in its URL and create a root context with it.
api.addPageDef("page://my.company.tld/my-plugin/profile-page",{contextType:{userId:"number"},paths:{// The left hand side is a react-router url matcher, which resolves on the right hand side to a context object or we could even use a context definition object to create a dynamic context"/profile/:userId":params=>({userId:Number(params.userId)}),// We'll also allow redirects from profile_%d to profile/%d"/profile_:userId":params=>`/profile/${params.userId}`},// ...});
Now, if we go to/profile/1
, we should see the data from the API displayed in our module.
What if we have more than one module that depends on the same data? Inlining our data adapter is simple enough, but that prevents us from reusing it in another module or plugin. Let's create a public data adapter which we can then reference in our module.
// in plugin's init method:api.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){// This can be an XHRreturnPromise.resolve({name:"Joe",id:context.userId});}});api.addModuleDef("module://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},dataAdapters:[{ref:"adapter://my.company.tld/my-plugin/profile"}],getContentComponent:async()=>(props)=><div> Hello,{props.name}! Your user ID is{props.id}.</div>,getContentProps:({ context, asyncData})=>({id:context.userId,name:asyncData.get("adapter://my.company.tld/my-plugin/profile").data.name})});
The CMS handles data loading in the background and makes the result directly available in our component. However, what happens while the module is being loaded or if some error occurred?
By default, any data adapter that we specify in our module definition must return a value before our module is rendered. That means thatasyncData.get("<adapter-uri-or-alias>").data
will always contain the adapter result.If we want to render something while loading the data or handle data errors in a special way, we can define custom elements for these cases:
import{ErrorBox}from"@alethio/ui/lib/ErrorBox";import{LoadingBox}from"@alethio/ui/lib/LoadingBox";// in plugin's init method:api.addModuleDef("module://my.company.tld/my-plugin/profile",{// ...getErrorPlaceholder:({ translation})=><ErrorBox>An error has occurred</ErrorBox>,getLoadingPlaceholder:({ translation})=><LoadingBox>Loading...</LoadingBox>});
Important notes:
- Error and loading placeholders also come into effect when loading the module's main content component (e.g. if it's loaded with a dynamic import)
- If a module throws an error, the error placeholder is rendered, if it exists. Otherwise nothing is rendered at all. There is no sensible default for the placeholder.
If we have an adapter that is slow to load, but we can already partially render the module, we can mark that adapter as optional and then manually handle the state changes in our adapter result:
import{observer}from"mobx-react";import{LoadStatus}from"plugin-api/LoadStatus";api.addModuleDef("module://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},dataAdapters:[{ref:"adapter://my.company.tld/my-plugin/profile",optional:true}],// Important: use MobX observer to react to state changesgetContentComponent:async()=>observer(({ asyncProfile})=><div>{asyncProfile.loadStatus===LoadStatus.Loading ?"Loading..." :asyncProfile.loadStatus===LoadStatus.Error ?"An error has occured" :`Hello,${asyncProfile.data.name}!`}</div>),getContentProps:({ asyncData})=>({asyncProfile:asyncData.get("adapter://my.company.tld/my-plugin/profile")})});
It is common to have a "main" content module on the page, or even more than one. If that module hasn't loaded yet, we might not want to render the rest of the page at all and wait until everything has stabilized by showing a page-wide spinner. To achieve this, we'll define loading and error placeholders in the page itself, while marking our module withpageCritical
in the configuration.
Important:pageCritical
modules can only be defined in the root context of each page
{// ..."pages": [{"def":"page://my.company.tld/my-plugin/profile-page","children": {"content": [ {"def":"module://my.company.tld/my-plugin/profile","pageCritical":true } ] } }]}
We can react to data changes by triggering events in our data loaders when we detect a change. For this purpose a special kind of object called a data watcher is created. This object must define anonData
event,watch
andunwatch
methods. After every data loader successfulload()
, a new watcher is created andwatch()
is called. Any previous watcher is destroyed by callingunwatch()
. Whenever anonData
event is triggered, the data loader does a reload on that adapter.
import{EventDispatcher}from"@puzzl/core/lib/event/EventDispatcher";classCustomDataWatcher{constructor(interval){this.interval=interval;this.onData=newEventDispatcher();}watch(){// Just for an auto-refresh every 5 secondsthis.timeoutId=setTimeout(()=>this.onData.dispatch(),this.interval);}unwatch(){clearTimeout(this.timeoutId);}}api.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){returnPromise.resolve({name:`Joe (@${Date.now()})`,id:context.userId});},createWatcher(context,lastDataResult){returnnewCustomDataWatcher(5000);}});
For this purpose we can use a predefined watcher, which is available through the runtimeplugin-api
module.
import{observable}from"mobx";import{ObservableWatcher}from"plugin-api/watcher/ObservableWatcher";constmyStore=observable({myObservable:2});// in plugin's init methodapi.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){returnPromise.resolve({name:`Joe (@${Date.now()})`,id:context.userId});},createWatcher(context,lastDataResult){returnnewObservableWatcher(()=>myStore.myObservable);}});setInterval(()=>{myStore.myObservable++;},3000);
Let's take our profile example. We might want for instance to pass the API endpoint URL by configuration, instead ofhardcoding it in the plugin. We'll modify the CMS config as follows:
{"plugins": [{"uri":"plugin://my.company.tld/my-plugin","config": {"profileApiUrl":"https://api.my.company.tld/profile/%d" } }]}
Then, we can update our data adapter to use that URL from the config:
import{HttpRequest}from"@puzzl/browser/lib/network/HttpRequest";// in plugin's init method:api.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){returnawaitnewHttpRequest().fetchJson(config.profileApiUrl.replace("%d",context.userId));}});
Global plugin configuration is useful when shared by multiple entities, but in other cases we might just want to pass some simple options to a single module or page, without changing its implementation. We can do this by providing anoptions
key together with the entity URI, in the CMSpages
config.
{"pages": [{"def":"page://my.company.tld/my-plugin/profile-page","children": {"content": [ {"def":"module://my.company.tld/my-plugin/profile","options": {"showTime":true },"pageCritical":true } ] } }]}
// in plugin's init methodapi.addModuleDef("module://my.company.tld/my-plugin/profile",{contextType:{},dataAdapters:[],getContentComponent:async()=>({ name, showTime})=><div>Hello,{name}!{showTime ?`Time is${Date.now()}` :null}</div>,getContentProps:({ options})=>({name:"Joe",showTime:options&&options.showTime})});
The locale string that is passed to theCms
component will propagate to every plugin and can be accessed inside each module's content component. Plugins can define translation strings per language, which are loaded by the CMS and accessible via a translation service.
The plugin will need to be modified like this:
constmyPlugin={//...getAvailableLocales(){return["en-US","zh-CN"];},asyncloadTranslations(locale:string){returnawaitimport("./translation/"+locale+".json");}}
An example translation file might look like this:
{"general.loading":"Loading","profile.header":"Hello, {name}!"}
Translation strings can be accessed from thetranslation
prop, which is available in module's react components (main content, loading and error placeholders):
api.addModuleDef("module://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},dataAdapters:[{ref:"adapter://my.company.tld/my-plugin/profile"}],getContentComponent:async()=>(props)=><div>{props.translation.get("profile.header",{"{name}":props.name})}</div>,getLoadingPlaceholder:({ translation})=><div>{translation.get("general.loading")}</div>,getContentProps:({ context, asyncData, translation, locale})=>({ translation,name:asyncData.get("adapter://my.company.tld/my-plugin/profile").data.name})});
There are some cases where we would like to override some translation strings from configuration, without rebuilding a plugin. We can easily do this by adding atranslations
key in theplugins
section of the CMS config:
{"plugins": [{"uri":"plugin://my.company.tld/my-plugin?v=1.0.0","config": {// ... },"translations": {"en-US": {"someKey":"My custom translation string" } } }]}
Each plugin has access to a runtime public API. The CMS exposes various functions and classes under theplugin-api
package. This is not a real npm package, but a faux package, created at runtime. The plugin can get access to it by doingrequire("plugin-api")
. If using webpack, make sure to mark everything starting withplugin-api
as an external, to allow the CMS to resolve it instead at runtime. Besides this API, the CMS also exposes thereact
,react-dom
,mobx
,mobx-react
andstyled-components
packages, to save the hassle of including separate copies in each plugin. For a comprehensive list of exported items, please see theplugin-api TypeScript definitions.
The CMS is a TypeScript first-class citizen. Theplugin-api npm package exports TypeScript definitions for the corresponding runtime as well as interfaces for defining plugins, modules, pages, contexts, adapters etc.
importReactfrom"react";import{IPlugin,IModuleDef,ITranslation}from"plugin-api";interfaceIProfileProps{translation:ITranslation;name:string;}interfaceIUserContext{userId:number;}interfaceIProfileData{name:string;}constprofileModule:IModuleDef<IProfileProps,IUserContext>={contextType:{userId:"number"},dataAdapters:[{ref:"adapter://my.company.tld/my-plugin/profile"}],getContentComponent:async()=>(props)=><div>{props.translation.get("profile.header",{name:props.name})}</div>,getLoadingPlaceholder:({ translation})=><div>{translation.get("general.loading")}</div>,getContentProps:({ context, asyncData, translation, locale})=>({ translation,name:(asyncData.get("adapter://my.company.tld/my-plugin/profile")!.dataasIProfileData).name})};constmyPlugin:IPlugin={init(config,api,logger,publicPath){// ...api.addModuleDef("module://my.company.tld/my-plugin/profile",profileModule);}}// tslint:disable-next-line:no-default-exportexportdefaultmyPlugin;
Due to the dynamic nature of the pages and the mapped routes, linking is done with a specialLink
component that uses page URIs instead of real URLs. This component also disables a link if the target page is not present in the configuration.
Example:
import{Link}from"plugin-api/component/Link";constmyModule={// ...getContentComponent:async()=>()=><div>{/* Notice the query string which is a serialized version of the context object of that page */}{/* If the page URI cannot be resolved, the link is deactivated */}<Linkto={`page://my.company.tld/my-plugin/profile-page?userId=1234`}> This is a link</Link></div>};
Important: for links to work, we also need to define a way of transforming the page URI back into a full URL. Because of this we need to add one more method to our page object:
api.addPageDef("page://my.company.tld/my-plugin/profile-page",{// ...buildCanonicalUrl:({ userId})=>`/profile/${userId}`,// ...});
We can also access the linking logic directly with thewithInternalNav
higher-order component:
import{withInternalNav}from"plugin-api/withInternalNav";classMyComponentextendsReact.Component{render(){return<buttononClick={()=>this.handleClick()}>A Link</button>}handleClick(){// Try to redirect to target page if it existif(this.props.internalNav.goTo("page://my.company.tld/my-plugin/non-existent")){// doesn't get here because we used a non-existent page URI}else{console.log("Target page doesn't exist");letresolvedUrl=this.props.internalNav.resolve("page://my.company.tld/my-plugin/profile-page?userId=1");if(resolvedUrl){location.href=resolvedUrl;}}}}constMyComponentWithNav=withInternalNav(MyComponent);
In our profile example page we might want to add a module that shows information about an external service associated with our user. Our profile API returns anuserId
and could also return aserviceId
, which we can use to query an external app for additional info. Our new module doesn't really care about theuserId
and, ideally, it should only depend on theserviceId
. This also allows us to put the module anywhere we have access to theserviceId
without tightly coupling it with the profile page. Given that now the module context is different from the page context, how we can go about adding the module in the profile page? With a new context definition:
api.addContextDef("context://my.company.tld/my-plugin/service-context",{parentContextType:{userId:"number"},contextType:{serviceId:"string"},dataAdapters:[{ref:"adapter://my.company.tld/my-plugin/profile"}],create(parentContext,parentData){letserviceId=parentData.get("adapter://my.company.tld/my-plugin/profile").data.serviceId;return{ serviceId};}});api.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){returnPromise.resolve({name:"Joe",serviceId:15})}});api.addModuleDef("module://my.company.tld/my-plugin/service-details",{contextType:{serviceId:"number"},dataAdapters:[],getContentComponent:async()=>({serviceId})=><div> Service ID:{serviceId}</div>,getContentProps:({ context})=>({serviceId:context.serviceId})});
We'll then update the config as follows:
{"pages": [{"def":"page://my.company.tld/my-plugin/profile-page","children": {"content": [ {"def":"module://my.company.tld/my-plugin/profile" }, {"def":"context://my.company.tld/my-plugin/service-context","children": [ {"def":"module://my.company.tld/my-plugin/service-details" } ] } ] } }]}
Refresh the browser page and you should see everything rendered correctly.
In some case dynamic context could be an unnecessary overhead, which can simply be solved by directly coupling several data adapters together. Building on the example given in the previous section, we could have an adapterservice-details
that returns data for a service, based on theserviceId
. Instead of creating a{ userId, serviceId }
context, we could simply get the profile data (that contains theserviceId
) by making theprofile
adapter a dependency of theservice-details
adapter.
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile",{contextType:{userId:"number"},asyncload(context,cancelToken){returnPromise.resolve({name:"Joe",serviceId:15})}});api.addDataAdapter("adapter://my.company.tld/my-plugin/service-details",{contextType:{userId:"number"},dependencies:["adapter://my.company.tld/my-plugin/profile"],asyncload(context,cancelToken,depData){let{ serviceId}=depData.get("adapter://my.company.tld/my-plugin/profile");returnPromise.resolve({ serviceId,someRemotelyFetchedData:{}});}});
Sometimes, we might want to take advantage of the CMS module rendering, without having to actually define one with a public URI and adding a separate entry in the CMS config. In this case, we can use theplugin-api/component/InlineModule
component.
import{InlineModule}from"plugin-api/component/InlineModule";constinlineModuleDef={dataAdapters:[{alias:"someAdapter",def:{contextType:{someData:"number"},load:asyncctx=>ctx.someData}}],getContentComponent:async()=>({ someData})=><div>Some data:{someData}</div>,getContentProps:({ asyncData})=>({someData:asyncData.get("someAdapter").data})};classSomeComponentextendsReact.Component{render(){return<InlineModulemoduleDef={inlineModuleDef}context={{someData:2}}logger={console}extraContentProps={{translation:this.props.translation}}/>;}}api.addModuleDef("module://my.company.tld/my-plugin/profile",{// ...getContentComponent:async()=>(props)=><div>{/* ... */}<SomeComponenttranslation={props.translation}/></div>,// ...});
Notes:
- This also allows us to create a context from our UI data, which is not otherwise available outside the rendering layer.
- Inline modules can only use inline data adapters
We might want to share the data fetching primitives between adapters or even with external plugins. For this purpose we can define a data source. The only requirement for a data source is that it must be an object with anasync init()
method. In this method we can do things such as opening an initial socket connection, or logging into an external API. The CMS will call this method after all plugins have been loaded, before bootstrapping the app. Please note the blocking nature of this call. The CMS will not continue until all data sources were successfully initialized.
constmyPlugin={init(config,api,logger,publicUrl){// This method is synchronous and we're only allowed to declare entities. If we want to do side-effects,// such as external API calls, establishing connections etc, those should be done in the data source init()letdataSource={asyncinit(){// do stuff},fetchSomeData(){return{a:2};}};api.addDataSource("source://my.company.tld/my-plugin/my-data-source",dataSource);// an adapter that uses the above data sourceapi.addDataAdapter("adapter://my.company.tld/my-plugin/my-adapter",{contextType:{},load:async()=>Promise.resolve(dataSource.fetchSomeData())});}}
(TODO) Make data sources accessible between plugins using their URIs.
There are cases when plugins depend on each other's data for initialization. Simple data adapter dependencies are supported when initializing a plugin's data source, like so:
// in plugin1api.addDataAdapter("adapter://my.company.tld/plugin1/some-data",{/*...*/load:async()=>2});// in plugin2api.addDataSource("source://my.company.tld/plugin-2/some-source",{dependencies:[{ref:"adapter://my.company.tld/plugin1/some-data",alias:"someData"}],init:async(depData)=>{console.log(depData.get("someData"));// Or depData.get("adapter://my.company.tld/plugin1/some-data")});});
Important: Only a subset of data adapter features are supported. For instance, it must not have nested dependencies or a contextType other than{}
(root context).
In some cases, we might want to make simple additions or overrides to existing plugins, or just prototype without going through the overhead of creating a new plugin repo + boilerplate, installing and deploying it.
We'll first create a new baked-in plugin, by adding a filemyPlugin.js
(ormyPlugin.ts
is using TypeScript) in our host app. We'll assume this to be in the same folder as ourApp
component:
The structure for inline plugins is similar to regular ones, except that init method no longer receives apublicPath
parameter. SeeIInlinePlugin for reference.
exportconstmyPlugin={init(config,api,logger){// ... regular plugin stuff ...},getAvailableLocales:()=>["en-US","zh-CN"],// We assume we can use the same translations as the host apploadTranslations:(locale)=>import("../translation/"+locale+".json")};
IMPORTANT: We also need to add the following to our webpack configexternals
section:
externals:[function(context,request,callback){if(/^plugin-api\/.+$/.test(request)){returncallback(null,'commonjs '+request);}callback();}]
Now let's pass the plugin to theCms
component instance in ourApp
component:
//...classApp/*...*/{//...render(){return<Cms//...inlinePlugins={{"inline-plugin://my.company.tld/my-plugin":()=>import("./myPlugin").then(({ myPlugin})=>myPlugin)}}>{/*...*/}</Cms>}}
Note: Inline plugins use a differentinline-plugin://
scheme, to differentiate them from externally loaded plugins.
Lastly, we need to add our plugin to the CMS config object:
{//..."plugins": [{//..."uri":"inline-plugin://my.company.tld/my-plugin"//... }]//...}
Assuming our app has adist
folder for the built assets, we can install the plugins inside this folder, then package and deploy the entire folder.
The following changes will be needed:
- In the CMS config we'll specify
"pluginsBaseUrl": "/plugins"
. - In the app repo we'll execute
$ acp install <plugin_npm_package_name_or_local_folder>
. See@alethio/cms-plugin-tool
for usage.
This will copy the built plugins insidedist/plugins
folder.
- Lastly, we need to update the plugin URIs in the config to also include the plugin version string. This version string can be found in the plugin manifest (package.json). In production mode, each plugin version is installed inside a separate subfolder (per version). In contrast, when we linked the plugins for development, a single folder was generated and the version string was not required.
{"plugins": [{"uri":"plugin://my.company.tld/my-plugin?v=1.0.0" }]}
This is achieved by copying the built plugin files to an external CDN and changingpluginsBaseUrl
config to point to that CDN. This means the base app can be deployed independently from the plugins. Whenever we publish a new version of the plugin we only need to update the version string in the config.
We can first install the plugins in a temporary folder:$ acp install --target /tmp/plugins <plugin_npm_package_or_local_folder>
And then sync the target folder structure with the external CDN via preferred method.
Help content can be defined per module by adding agetHelpComponent
method in the module definition.
Example:
letmodule={//...getHelpComponent:()=>({ translation,/* ... */})=>translation.get("my.help.content.key")}
Help can be displayed by turning on/off a special "help mode". In help mode, the user can hover a module and if that module has help, it will be highlighted with a border and "help" mouse cursor. When clicked, the help content for that module is rendered with a user-defined component. Help mode can be controlled from the callback which is passed toCms
component as child.
Example:
importReactfrom"react";importReactDOMfrom"react-dom";import{Observer}from"mobx-react";import{Cms}from"@alethio/cms";import{Layer}from"@alethio/ui/lib/overlay/Layer";import{Mask}from"@alethio/ui/lib/overlay/Mask";import{HelpIcon}from"@alethio/ui/lib/icon/HelpIcon";import{Toolbar}from"@alethio/ui/lib/layout/toolbar/Toolbar";import{ToolbarItem}from"@alethio/ui/lib/layout/toolbar/ToolbarItem";import{ToolbarIconButton}from"@alethio/ui/lib/layout/toolbar/ToolbarIconButton";import{Button}from"@alethio/ui/lib/control/Button";exportclassAppextendsReact.Component{// ...render(){return<Cms// ...HelpComponent={({ children, module, onRequestClose})=>ReactDOM.createPortal(<><Mask/><Layer>{children}<ButtononClick={onRequestClose}>Got it</Button></Layer></>,document.body)}>{({ slots, routes, helpMode})=>{return<div><Toolbar>{/* Observer prevents the whole page from reacting whenever helpMode is toggled */}<Observer>{()=><ToolbarItemtitle={helpMode.isActive() ?"Toggle off help mode" :"Toggle on help mode"}><ToolbarIconButtonactive={helpMode.isActive()}Icon={HelpIcon}onClick={()=>helpMode.toggle()}/></ToolbarItem>}</Observer>{slots&&slots["toolbar"]}</Toolbar>{/* ... */}<div>{routes}</div></div>;}}</Cms>;}}
- Clone the repo
npm install
npm run build-dev
ornpm run watch
.- Link the library in your app with npm link or npm install with local path.
About
Alethio Content Management System