Extending OpenCms: Developing a Custom Widget

OpenCms 7 Development

April 2008


Extending and customizing OpenCms through its Java API

Structured Content Types

Support for structured content is a key feature of OpenCms. Structured content types allow different templates to be used to re-skin a site, or to share content with other sites that have a different look. Structured content types are defined by creating XSD schemas and placing them into modules. Once a new content type has been defined, the Workplace Explorer provides a user interface to create new instances of the content and allows it to be edited. There are some sample content types and templates that come with the Template One group of modules. These content types are very flexible and allow a site to be built using them right away. However, they may not fit our site requirements. In general, site requirements and features will determine the design of the structured content types and templates that need to be developed.

BlogEntry Content Type

For designing a blog website it is required that the content type contains blog entries. The schema file for the BlogEntry content type looks like the following :

<!-- ======================================================== 
Content definition schema for the BlogEntry type
========================================================== -->
<!-- 1. Root Element -->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<!-- 2. Define the location of the schema location -->
<xsd:include schemaLocation="opencms://opencms-xmlcontent.xsd"/>
<!-- 3. Root element name and type of our XML type -->
<xsd:element name="BlogEntrys" type="OpenCmsBlogEntrys"/>
<!-- 4. Definition of the type described above -->
<xsd:complexType name="OpenCmsBlogEntrys">
<xsd:element name="BlogEntry" type="OpenCmsBlogEntry" minOccurs="0" maxOccurs="unbounded"/>
<!-- 5. Data field definitions -->
<xsd:complexType name="OpenCmsBlogEntry">
<xsd:element name="Title" type="OpenCmsString" minOccurs="1" maxOccurs="1" />
<xsd:element name="Date" type="OpenCmsDateTime" minOccurs="1" maxOccurs="1" />
<xsd:element name="Image" type="OpenCmsVfsFile" minOccurs="0" maxOccurs="1" />
<xsd:element name="Alignment" type="OpenCmsString" minOccurs="1" maxOccurs="1" />
<xsd:element name="BlogText" type="OpenCmsHtml" minOccurs="1" maxOccurs="1" />
<xsd:element name="Category" type="OpenCmsString" minOccurs="0" maxOccurs="10" />
<!-- 6. locale attribute is required -->
<xsd:attribute name="language" type="OpenCmsLocale" use="required"/>
<!—optional code section -->
<!-- Mappings allow data fields to be mapped to content properties -->
<mapping element="Title" mapto="property:Title" />
<mapping element="Date" mapto="attribute:datereleased" />
</mappings><!-- Validation rules for fields -->
<rule element="BlogText" regex="!.*[Bl]og.*" type= "warning" message="${key.editor.warning.BlogEntry. dontallowblog|${validation.path}}"/>
<!-- Default values for fields -->
<default element="Date" value="${currenttime}"/>
<default element="Alignment" value="left"/>
<!-- user interface widgets for data fields -->
<layout element="Image" widget="ImageGalleryWidget"/>
<layout element="Alignment" widget="SelectorWidget" configuration="left|right|center" />
<layout element="Category" widget="SelectorWidget"
configuration="silly|prudent|hopeful|fearful| worrisome|awesome" />
<layout element="BlogText" widget="HtmlWidget"/>
<!-- UI Localization -->
<resourcebundle name="com.deepthoughts.templates.workplace"/>
<!-- Relationship checking -->
<relation element="Image" type="strong" invalidate="node" />
<!-- Previewing URI -->
<preview uri="${previewtempfile}" />
<!-- Model Folder for content models -->
<modelfolder uri="/system/modules/com.deepthoughts.templates /defaults/" />

The BlogEntry content type file is named as blogentry.xsd and it placed in the folder named schemas in modules.

Designing a Custom Widget

Referring to the highlighted code in BlogEntry content type schema file we can see that the category field is populated from a drop-down list provided by SelectorWidget. The SelectorWidget obtains its values from the static configuration string defined within the blog schema file. This design is problematic as we would like category values to be easily changed or added. Ideally, the list of category values should be able to be updated by site content editors. Fortunately, we can create our own custom widget to handle this requirement.

An OpenCms widget is a Java class that implements the I_CmsWidget interface, located in the org.opencms.widgets package. The interface contains a number of methods that must be implemented. First there are some methods dealing with instantiation and configuration of the widget:

  • newInstance: This returns a new instance of the widget.
  • setConfiguration: This method is called after the widget has been initialized to configure it. The configuration information is passed as a string value coming from the declaration of the widget within the schema file of the content type using it.
  • getConfiguration: This is called to retrieve the configuration information for the widget.

Next, there are some methods used to handle the rendering. These methods are called by widget enabled dialogs that might be used in a structured content editor or an administration screen. The methods provide any Javascript, CSS, or HTML needed by the widget dialogs:

  • getDialogIncludes: This method is called to retrieve any Javascript or CSS includes that may be used by the widget.
  • getDialogInitCall: This method may return Javascript code that performs initialization or makes calls to other Javascript initialization methods needed by the widget.
  • getDialogInitMethod: This method may return Javascript code containing any functions needed by the widget.
  • getDialogHtmlEnd: This method is called at the end of the dialog and may be used to return an HTML or Javascript needed by the widget.
  • getDialogWidget: This method returns the actual HTML and Javascript used to render the widget along with its values.
  • getHelpBubble: This method returns the HTML for displaying the help icon relating to this widget.
  • getHelpText: This method returns the HTML for displaying the help text relating to this widget.

Lastly, there are some methods used to get and set the widget value:

  • getWidgetStringValue: This method returns the value selected from the widget.
  • setEditorValue: This method sets the value into the widget.

All these methods have base implementations in the A_CmsWidget class. In most cases, the base methods do not need to be overridden. As such, we will not cover all the methods in detail. If it is necessary to override the methods, the best way to get an idea of how to implement them is to look at the code using them.

All widgets are used in dialog boxes which have been enabled for widgets, by implementing the I_CmsWidgetDialog interface. There are two general instances of these dialogs, one is used for editing structured XML content, the other is found in any dialog appearing in the Administration View. The two classes implementing this interface are:


The CmsWidgetDialog class is itself a base class, which is used by all dialogs found in the Administrative View.

Before designing a new widget, it is useful to examine the existing widget code. The default OpenCms widgets can be found in the org.opencms.widgets package. All the widgets in this package subclass the A_CmsWidget class mentioned earlier. Often, a new widget design may be subclassed from an existing widget.

Designing the Widget

As mentioned earlier, we would like to have a widget that obtains its option data values dynamically rather than from a fixed configuration string value. Rather than create a widget very specific to our needs, we will use a flexible design where the data source location can be specified in the configuration parameter. The design will allow for other data sources to be plugged into the widget. This way, we can use a single widget to obtain dynamic data from a variety of sources.

To support this design, we will use the configuration parameter to contain the name of a Java class used as a data source. The design will specify a pluggable data source through a Java interface that a data source must implement. Furthermore, a data source can accept parameters via the widget configuration string.

With this design, an example declaration for a widget named CustomSourceSelectWidget would look like this:

<layout element="Category" 
option1='some config param'|
option2='another param'"

This declaration would appear in the schema of a content type, using the widget as covered earlier. The configuration parameter consists of name/value pairs, delimited by the vertical bar character. Each name/value pair is separated by the equal to sign and the value is always enclosed in single quotes. The design requires that at least the source parameter be specified. Additional parameters will depend upon the specific data source being used.

The example declaration specifies that the data field named Category will use the CustomSourceSelectWidget widget for its layout. The configuration parameter contains the name of the Java class to be used to obtain the data source. The data source will receive the two parameters named option1 and option2 along with their values. Next, lets move on to the code to see how this all gets implemented.

The Widget Code

By examining the existing OpenCms widget code base, we see that the widget can be based on the SELECT widget, which is implemented by the CmsSelectWidget class. Recall that the SELECT widget obtains its option values statically from the configuration parameter. We will subclass the widget to modify its behavior, so that it will get the data using the Java data source instead. This turns out to be quite straightforward, requiring only a few method overrides:

public class CmsCustomSourceSelectWidget extends CmsSelectWidget { 
/** The log object for this class. */
private static final Log LOG = CmsLog.getLog(CmsCustomSourceSelectWidget.class);
/** The list of select values */
private List m_selectOptions = null;
/** The widget data source */
I_WidgetSelectSource m_iDataSource = null;
/** The configuration options */
CustomSourceConfiguration m_config;
public CmsCustomSourceSelectWidget() {
public I_CmsWidget newInstance() {
// create a new widget using the config parameters
return new CmsCustomSourceSelectWidget();

The class starts with the default constructor and some member variables which we'll discuss later on. The newInstance method is overridden to return an instance of the widget. To manage the configuration information, the setConfiguration method is overridden. This method gets called right after a widget has been constructed to allow it to initialize. The method uses the data source to read the option data:

public void setConfiguration(String configuration) { 
if (m_iDataSource == null) {
m_config = new CustomSourceConfiguration(configuration);
String strClassName = options.getConfigValue("source");
// read the class name for the data source
// and instantiate it
Class sourceClazz;
try {
sourceClazz = Class.forName(strClassName);
m_iDataSource = (I_WidgetSelectSource)sourceClazz.newInstance();
} catch (Exception e) {
// Log the error
LOG.error(Messages.get().getBundle().key(Messages.LOG_DATASOURCE_INIT_ERROR_2, strClassName), e);
// since it failed use the
// default source provider to be nice
m_iDataSource = new DefaultDS();
// set the configuration


After calling the base class, an instance of the CustomSourceConfiguration class is created with the configuration data. The class is used to encapsulate the parsing and formatting of configuration options. It also provides an easy way to retrieve the configuration values:

public class CustomSourceConfiguration { 
private static final Log LOG = CmsLog.getLog(CustomSourceConfiguration.class);
protected final static String OPTION_DELIMITER = "|";
protected final static String OPTION_VALUE_BEGIN="='";
protected final static String OPTION_VALUE_END="'";
/** List of option values */
private Hashtable<String, String> m_htOptions;
public CustomSourceConfiguration(String configuration) {
private void parseOptions(String config) {
m_htOptions = new Hashtable<String, String>();
String [] aParms = CmsStringUtil.splitAsArray(config, OPTION_DELIMITER);
for (int i=0; i<aParms.length; i++) {
boolean bBadParamFormat = false;
// read the parameter name
String strParm = aParms[i];
int nParmNameBegin = 0;
int nParmNameEnd = strParm.indexOf(OPTION_VALUE_BEGIN);
if (-1 != nParmNameEnd) {
// parameter name
String strParmName = strParm.substring(nParmNameBegin, nParmNameEnd);
// parameter value
int nParmValueStart = nParmNameEnd + OPTION_VALUE_BEGIN.length();
int nParmValueEnd = strParm.indexOf(OPTION_VALUE_END, nParmValueStart);
if (-1 != nParmValueEnd) {
String strParmVal = strParm.substring(nParmValueStart, nParmValueEnd);
// add the param name-value pair
m_htOptions.put(strParmName, strParmVal);
} else {
bBadParamFormat = true;
} else {
bBadParamFormat = true;
if (bBadParamFormat && LOG.isInfoEnabled()) {
LOG.info(Messages.get().getBundle().key (Messages.ERR_MALFORMED_SELECT_OPTIONS_1, strParm));
public String getConfigValue(String ConfigName) {
return (String) m_htOptions.get(ConfigName);

Back in the setConfiguration method, the source parameter is obtained from the configuration data. This value is then used to construct an instance of the data source using Java reflection. In case of an error, it is logged to the console and a fallback source is used. The fallback source returns the error message in the option value to indicate that there was a configuration problem. After the data source has been instantiated, the configuration information is passed to it. Note that the data source must adhere to the I_WidgetSelectSource interface. We will describe that interface later on.

The next method in the class is the getDialogWidget method. This method returns the actual HTML for the widget:

public String getDialogWidget(CmsObject cms, I_CmsWidgetDialog widgetDialog, I_CmsWidgetParameter param) { 
String id = param.getId();
// build the SELECT HTML
StringBuffer result = new StringBuffer(16);
result.append("<td class="xmlTd" style="height: 25px;"><select class="xmlInput");
if (param.hasError()) {
result.append(" xmlInputError");
result.append("" name="");
result.append("" id="");
// read the option data values
// finish the HTML
if (null != m_selectOptions) {
String selected = getSelectedValue(cms, param);
Iterator i = m_selectOptions.iterator();
while (i.hasNext()) {
SelectOptionValue option = (SelectOptionValue) i.next();
// create the option
result.append("<option value="");
if ((selected != null) && selected.equals(option.getValue())) {
result.append(" selected="selected"");
return result.toString();

The code was copied from the base CmsSelectWidget implementation as it is very similar. After building the SELECT tag, the option values are read from the data source. This is encapsulated in the getSelectOptionData method which places the data list into a member variable. The option tags are then built from the data list.

The getSelectedValue method is used to retrieve the value of any previously selected option value. This method is implemented by the base class, and does not need to be changed.

While building the Option tags, the code looks for a current value and sets the SELECTED attribute, if a match is found. This ensures the state of any previously selected value. At the end of the class is the method used to obtain the option values from the data source:

protected List getSelectOptionData(CmsObject cms) { 
// set the configuration in the data source
// read the option values
// data values are not cached by default, but can be
// cached by setting the configuration option
// "cacheData='true'"
String strCache = m_config.getConfigValue("cacheData");
if (null!=strCache && strCache.equalsIgnoreCase("true")) {
if (m_selectOptions == null) {
m_selectOptions = m_iDataSource.getValues(cms);
return m_selectOptions;
} else {
return m_iDataSource.getValues(cms);

The method first passes the configuration information to the data source to give it a chance to initialize itself. It then reads the data from the data source. By default, the data is read each time the method is called. But in some cases, this may be time consuming. To speed this up, the method supports caching the result if the cacheData option parameter is set.

That's it for the widget class. Next we'll go over the data source interface, which is very straightforward.

Custom Source Interface and Implementations

The interface designed for data sources is quite simple, consisting of only two methods:

public interface I_WidgetSelectSource { 
* This method is called after the data source is
* constructed and before the option values are
* retrieved. It passes the configuration string to
* the widget.
* @param config String value of the configuration string
public void setConfiguration(CustomSourceConfiguration config);
* Returns a List of SelectOptionValue objects to be
* displayed in the SELECT list.
public List getValues(CmsObject cms);

As seen in the widget code, the setConfiguration method is first called to give the data source a chance to initialize and capture any configuration data. The getValues method is then called to retrieve the option data. The option data must be returned in a List of SelectOptionValue objects, which is a simple bean class containing name/value pairs used for the option tag.

Let's have a look at couple of data sources that implement this interface. First, there is the DefaultDS class. This data source is used as a fallback in case the configured one can not be instantiated:

public class DefaultDS implements I_WidgetSelectSource { 
public DefaultDS() {
public List getValues(CmsObject cms) {
ArrayList<SelectOptionValue> aVals = new ArrayList<SelectOptionValue>();
aVals.add(new SelectOptionValue(Messages.get().getBundle().key(Messages.DEFAULT_DS_ERROR_MSG_0), "null"));
return aVals;
public void setConfiguration(CustomSourceConfiguration config) {
// don't care

The getValues method constructs an ArrayList and adds a single value to it. The value is an error string indicating that the configuration has failed. The string is retrieved using the OpenCms Messages class, which is discussed later on. The data source doesn't use the configuration data. So, the setConfiguration method is empty.

Now let's look at a more interesting data source. Recall that we would like the select widget to retrieve values from some list that content editors can edit. A structured content type is a perfect candidate for such a list. The valuelist content type will allow many instances of data lists to be created:

<!-- ============================================================ 
XSD content definition file for the ValueList type
=============================================================== -->
<xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
<xsd:include schemaLocation="opencms://opencms-xmlcontent.xsd" />
<xsd:element name="ValueLists" type="OpenCmsValueLists"/>
<xsd:complexType name="OpenCmsValueLists">
<xsd:element name="ValueList" type="OpenCmsValueList" minOccurs="0" maxOccurs="unbounded"/>
<xsd:complexType name="OpenCmsValueList">
<xsd:element name="Value" type="OpenCmsString" minOccurs="1" maxOccurs="1000" />
<xsd:attribute name="language" type="OpenCmsLocale" use="optional"/>

The content type has a single field named Value, and has an arbitrary upper limit of 1000 items. To install a content type the resourcetype and explorertype entries need to be added to the opencms-modules.xml configuration file. First is the resource type entry:

<type class="org.opencms.file.types.CmsResourceTypeXmlContent" name="valuelist" id="3002">
<param name="schema">

And then the explorer type entry:

<explorertype name="valuelist" key="fileicon.valuelist" icon="xmlcontent.gif" reference="xmlcontent"> 
<newresource uri="newresource_xmlcontent.jsp? newresourcetype=valuelist" order="25" autosetnavigation="false"
autosettitle="false" info="desc.valuelist"/>
<accessentry principal="ROLE.WORKPLACE_USER" permissions="+r+v+w+c"/>

After adding the new content type, a new list can be created within the Workplace Explorer. The list will maintain the Category values assigned to Blog entries and will be placed somewhere that the content editors can access it.

The last thing needed is a data source that gets its option values from an instance of this content type. Again, we will use a design that is more general in purpose rather than specific. The data source class will be able to read values from any content type that has repeating fields. This way it will not be limited to reading list values from the ValueList content type. This will be done by using the configuration parameter to instruct the data source how to get the values. The configuration string will need to have these options:

  • source: This parameter is required for the widget. In this case it will contain the name of the generic data source class.
  • contenttype: This will contain the type of the structured content that data will be obtained from.
  • location: This will specify the location of the resource in the VFS.
  • fieldname: This will contain the name of the field within the resource that holds the data values. The data source will read all occurrences of this field and return them as INPUT option values.


Now lets look at the code which implements this. The data source class is named ContentFieldListDS:

public class ContentFieldListDS implements I_WidgetSelectSource { 
/** The content type containing the list of values */
String m_strContentType;
/** VFS path to the instance of the resource */
String m_strLocation;
/** Name of the field within the resource */
String m_strFieldname;
* Public constructor needed
public ContentFieldListDS() {
m_strContentType = null;
m_strLocation = null;
m_strFieldname = null;

The member variables will contain the configuration data. They are initialized in the constructor, and then set in the setConfiguration method:

public void setConfiguration(CustomSourceConfiguration config)
// get the configuration options
m_strContentType = config.getConfigValue("contenttype");
m_strLocation = config.getConfigValue("location");
m_strFieldname = config.getConfigValue("fieldname");
private boolean isConfigValid() {
return m_strContentType != null &&
m_strLocation != null &&
m_strFieldname != null;

A utility method is used to check the configuration parameters. Last is the getValues method, where the content values are actually read from the data source:

public List getValues(CmsObject cms) { 
ArrayList<SelectOptionValue> lstVals = new
if (false == isConfigValid()) {
lstVals.add(new SelectOptionValue("Missing or invalid configuration options!", "null"));
} else {
// read the resource containing the values, in the specified location
try {
CmsResource res = cms.readResource(m_strLocation);
// check it against the desired type
if (m_strContentType.equalsIgnoreCase (OpenCms.getResourceManager(). getResourceType(res).getTypeName())) {
// retrieve the values
CmsXmlContent content =CmsXmlContentFactory.unmarshal (cms, cms.readFile(res));
// using the specified fieldname
List lVals = content.getValues(m_strFieldname, cms.getRequestContext().getLocale());
Iterator j = lVals.iterator();
while (j.hasNext()) {
I_CmsXmlContentValue iVal = (I_CmsXmlContentValue)j.next();
// add the value
lstVals.add(new SelectOptionValue (iVal.getStringValue(cms) iVal.getStringValue(cms)));
} else {
lstVals.add(new SelectOptionValue("Specified resource (" + m_strLocation + ") is not of type:" + m_strContentType, "null"));
} catch (CmsException e) {
// return the error in the list to be nice
lstVals.add(new SelectOptionValue("Error reading " + m_strLocation + ": " + e.getMessage(), "null"));
return lstVals;

The method begins by allocating a list to contain the returned values. If the configuration values cannot be set properly, then an error return message is returned in the list. Otherwise, the routine reads the configured CmsResource. Another validation check is done to insure that the content type of the resource matches the configured one. Again, an error message is returned if this is not the case.

After the validation check, the resource is read and unmarshalled. This converts its XML representation into a CmsXmlContent instance. After this is done, the configured content fields are read into an array and then converted into SelectOptionValue instances. For safety, the entire effort is surrounded with a catch block which returns any caught errors. The error messages have not been externalized as done in the earlier class. This exercise will be left to the reader. But first we will cover the Messages class used for doing this.


Using OpenCms Message Strings for Localization

OpenCms provides full support for localization. The messages.properties file to contains user interface strings. The property values can also be used to localize message strings in Java classes. The I_CmsMessageBundle interface provides the support for this. The interface can be a bit confusing at first, but fortunately, most of the methods are already implemented in the A_CmsMessageBundle class. To support localized strings in our own classes, we just need to provide a subclass of the A_CmsMessageBundle class. Each Java package can have its own messages.properties file with a corresponding Java class. The convention is to name the class Messages. Let's take a look at the one used by the widgets:

public final class Messages extends A_CmsMessageBundle { 
/** Name of the used resource bundle. */
private static final String BUNDLE_NAME = "com.deepthoughts.widgets.messages";
/** Static instance member. */
private static final I_CmsMessageBundle INSTANCE = new Messages();
* Hides the public constructor for this utility class.<p>
private Messages() {
// hide the constructor
* Returns an instance of this localized message accessor.<p>
* @return an instance of this localized message accessor
public static I_CmsMessageBundle get() {
return INSTANCE;
* Returns the bundle name for this OpenCms package.<p>
* @return the bundle name for this OpenCms package
public String getBundleName() {
/** Message constants for key in the resource bundle. */
public static String DEFAULT_DS_ERROR_MSG_0 = "DEFAULT_DS_ERROR_MSG_0";

A static declaration containing the name of the bundle appears first. The name will always match the package that the Java class is in. Next, is the static declaration and instantiation of the class. There needs to be only one instance of the class, as messages are a read-only resource. To further ensure that only one instance of the class is created, the default constructor is declared private. The next two methods return the class instance and the name of the bundle respectively.

Last in the class is the list of localized messages. The convention used to format the messages is to declare the message name as a string constant and to have the message value match the constant name. A localized message can then be retrieved through its message constant value, for example:

String msg = Messages.get().getBundle(). key(Messages.DEFAULT_DS_ERROR_MSG_0)

Looking at the variations of the key method on the I_CmsMessageBundle interface, we see that it supports the ability to parameterize the message string with up to three values. The convention used to format message keys is to append a numeral at the end, indicating the number of parameters in the message. This was done in the error handling of the widget's setConfiguration method to return the name of the class and the exception message:

key(Messages.LOG_DATASOURCE_INIT_ERROR_2, strClassName), e);

Relating message constant values to the message strings in the messages.properties file is easy. Each entry in the properties file is named according to the string value of the message constant:

# messages.properties for widget
DEFAULT_DS_ERROR_MSG_0 = Missing or incorrectly configured option value - please fix
LOG_DATASOURCE_INIT_ERROR_2 = Error instantiating DataSource object of class "{0}" - "{1}"

Parameterized messages are formatted using numbered macro placeholders, as shown previously.

Registering the Widget with OpenCms

The last step in creating the widget is to register it with OpenCms. Widgets are registered in the XML file located in the configuration directory:


To add the widget, locate the <WIDGETS> section and add the widget definition:

<!-- Custom Widget -->

The class parameter contains the fully qualified class name of the widget. The alias is the name that will be used in the layout declaration of a schema file using the widget. Any changes made to the file will require a restart of OpenCms to take effect.

After compiling the code, deploying the class files and registering the widget it can finally be used. Here is an example of what a schema file using the widget will look like:

<layout element="Category" widget="CustomSourceSelectWidget" configuration= "source='com.deepthoughts.widgets.sources.ContentListDS'| 

The layout goes into the blogentry.xsd file which was mentioned in the beginning of this article. The declaration specifies that the Category field uses the CustomSourceSelectWidget widget. The ContentListDS class is specified as the data source and it uses a content type of ValueList. The resource instance of the ValueList content type is located at /Blogs/BlogCategories and the field within the content type containing the values is named Category. After making these changes to the schema file, the content editor will have a pull-down that obtains the values dynamically.


In this article, we talked about how to create a custom widget. We first covered the interfaces necessary to implement a widget and then went through the design of a widget for the blog content. After that, we went over the widget code and how to register a widget with OpenCms. Although the widget we developed is used to retrieve category values from a list, we've designed it to have broader use. It's an easy matter to configure the widget to read from a different resource containing different values or a different resource type altogether. The widget design allows for other data sources to be configured.



Books to Consider

Managing and Customizing OpenCms 6 Websites
$ 12.00
OpenCms 7 Development
$ 16.20
comments powered by Disqus

An Introduction to 3D Printing

Explore the future of manufacturing and design  - read our guide to 3d printing for free