Parsing Sitecore rules and running them in pure JS (with no BE calls except GraphQL).
Have you ever used rule engine in Sitecore? Probably most of us did used it for personalization or maybe insert option rules.
Personally I LOVE Sitecore rule engine. It’s such a powerful tool that many CMS frameworks are missing. Very easy to extend and allows complex logic.
The problem — this rule engine runs with .NET and in XM Cloud world we simply can’t just use it in JSS.
Solution — let’s build our own rule engine that would re-use our Sitecore rules!
So how do we get there?
Step 1 — Let’s parse the rules XML on the client-side
Luckily GraphQL query can expose the rules field XML that we need.
We are going to use xml-js
library for the XML parsing in JS.
This is an example of how Sitecore stores the rule in XML:
<ruleset>
<rule uid=\"{A077945D-9CD4-4F5B-89D9-606BF2C2A68C}\">
<conditions>
<or uid=\"EC0534D499B649AAAC2D9BB29B78F6E0\">
<or uid=\"6C873F210A0E41C7BB14709051F53E88\">
<condition id=\"{DA0D1AEA-0144-4A40-9AF0-3123526C9163}\"
uid=\"C3D1E24D3388498C81078B2F19666273\" fieldname=\"testfield\" />
<and uid=\"11F4BDE8FFD2414BB6EDF361F5B22E8D\">\r\n
<condition id=\"{DA0D1AEA-0144-4A40-9AF0-3123526C9163}\"
uid=\"B2A6F7868DE84FBFB9D0F326DE2CD702\" fieldname=\"testfield2\" />
<condition id=\"{C186B6C0-C702-4A05-93E4-982F1FCF16AE}\"
uid=\"2DF34BDFCF7D4BC392C75E8C36878957\"
operatorid=\"{2E67477C-440C-4BCA-A358-3D29AED89F47}\" FieldTypeName=\"12\" />
</and>
</or>
<and uid=\"6343FBA4B66A4D278E5C36611D556138\">
<condition id=\"{C186B6C0-C702-4A05-93E4-982F1FCF16AE}\"
uid=\"7E3BF240706B4C63988F43A7D0153C7F\"
operatorid=\"{2E67477C-440C-4BCA-A358-3D29AED89F47}\" FieldTypeName=\"23\" />
<condition id=\"{DA0D1AEA-0144-4A40-9AF0-3123526C9163}\"
uid=\"C6D827E437744F64AAB486A4E78F960A\" fieldname=\"24\" />
</and>
</or>
</conditions>
</rule>
<rule uid=\"{83C588CE-EAD0-4152-9842-F8F43C4549E5}\">
<conditions>
<condition id=\"{61C016A2-5210-4F1D-A663-BDA18BE487F6}\"
uid=\"E7CC5F4A9F554595A56491D6EBC62019\" fieldname=\"testfield\"
operatorid=\"{2E67477C-440C-4BCA-A358-3D29AED89F47}\"
value=\"test\" except=\"true\" />
</conditions>
</rule>
</ruleset>
As you can see the XML has nodes with rules, conditions and operators that references to the corresponding items in Sitecore.
We are going to store the parsed XML in the JS model to later run it on the client-side.
module.exports = function(ruleXml, ruleEngineContext){
ruleXml = ruleXml.replace('\t','').replace('\n','').replace('\r','');
xmlDoc = xmlParser.xml2js(ruleXml, {compact: false, spaces: 4});
var rulesetNode = xmlDoc.elements.find(x => x.type == "element" && x.name == "ruleset");
if(!rulesetNode ||
rulesetNode.type != "element" ||
rulesetNode.name != "ruleset")
{
throw new Error("Ruleset node is missing.");
}
var parsedRule = {
rules: []
};
var rulesNodes = rulesetNode.elements.filter(x => x.type == "element" && x.name == "rule");
if(!rulesNodes)
{
console.log('Rule nodes are missing.');
return false;
}
rulesNodes.forEach(ruleXmlNode => {
var rule = {
conditions: []
};
var attributeKeys = Object.keys(ruleXmlNode.attributes);
attributeKeys.forEach(attr => {
rule[attr] = ruleXmlNode.attributes[attr];
});
var conditionsRootNode = ruleXmlNode.elements.find(x => x.type == "element" && x.name == "conditions");
conditionsRootNode.elements.filter(x => x.type == "element").forEach(conditionXmlNode => {
var parsedCondition = parseCondition(conditionXmlNode, ruleEngineContext);
if(parsedCondition)
{
rule.conditions.push(parsedCondition);
}else {
throw new Error('Condition wasnt parsed', conditionXmlNode);
}
});
parsedRule.rules.push(rule);
});
return parsedRule;
}
Step 2 — Implement rule evaluation logic
But how are we going to run the conditions and operators logic that we had in Sitecore? Unfortunately we can’t reuse the existing BE code that we had in Sitecore for that.
Instead we are going to map these rules and re-create that code in JS.
For that we are going to create dictionaries with conditions and rules definitions in JS:
this.commandDefinitions = [];
this.ruleDefinitions = [];
this.operatorDefinitions = [];
registerCommand(id, command) {
this.commandDefinitions[id] = command;
}
registerRule(id, rule) {
this.ruleDefinitions[id] = rule;
}
registerOperator(id, operator) {
this.operatorDefinitions[id] = operator;
}
And now let’s write code that would actually run the rule:
module.exports = function (parsedRule, ruleEngineContext) {
var ruleResult = true;
parsedRule.rules.forEach(rule => {
if (rule.conditions && rule.conditions.length > 0) {
var result = true;
rule.conditions.forEach(condition => {
var conditionId = condition.id ? condition.id : condition.className;
var isExcept = typeof(condition.except) !== "undefined" && condition.except === 'true';
var conditionFunction = ruleEngineContext.ruleEngine.ruleDefinitions[conditionId];
if (typeof(conditionFunction) === "undefined" || !condition) {
throw new Error('Rule definitions missing for id ' + conditionId);
}
var conditionResult = conditionFunction(condition, ruleEngineContext);
if(isExcept)
{
conditionResult = !conditionResult;
}
result = result && conditionResult;
});
}
ruleResult = ruleResult && result;
});
return ruleResult;
}
Wow. Now we have a parser and a runner for our rule engine.
It wouldn’t work of course because we have zero implementations of conditions or operators.
Step 3 — Implement the conditions and operators for the rule
I’m not going to cover all of the rules and operator implementations here (you would find them in the repository though)
But here are some example for the “day of the month” rule condition:
module.exports = function(rule, ruleContext) {
var dayNumberValue = rule.DayNumber;
var operatorId = rule.operatorid;
var dayNumber = Number.parseInt(dayNumberValue);
var operator = ruleContext.ruleEngine.operatorDefinitions[operatorId];
if(!operator)
{
throw new Error("Operator definition is missing for id ", rule.operatorId);
}
var operatorContext = {
parameter1: ruleContext.dateTime.now,
parameter2: dayNumber
}
return operator(operatorContext, ruleContext);
}
And it needs to be registered of course:
var dayOfMonthRule = require('./dayOfMonthRule')
ruleEngine.registerRule('{816F72B0-DBE1-4D39-A68E-682FFC31133E}',
dayOfMonthRule)
And same can be done for the operator too:
//works both for numbers and strings
module.exports = function(operatorContext, ruleContext) {
return operatorContext.parameter1 == operatorContext.parameter2;
}
And the registration:
//numbers
var isEqualTo = require('./isEqualTo')
ruleEngine.registerOperator('{066602E2-ED1D-44C2-A698-7ED27FD3A2CC}',
isEqualTo)
Step 4 — Run the rule!
And now finally we can actually use our rule engine on the FE and run the rule!
function parseAndRun(xml, ruleEngineOptions) {
var ruleEngineOptions = ruleEngineOptions ? ruleEngineOptions : {};
var ruleEngine = new JssRuleEngine(ruleEngineOptions);
var ruleResult = ruleEngine.parseAndRunRule(xml);
return ruleResult;
}
Note that this code snippets is just a gist of actual implementation and the whole working code can be found on Github here:
You can also use it as NPM package!
So far the list of supported rules and operators is limited, but I’m planning to actively extend the current prototype.
npm i sitecore-jss-rule-engine
How can you use this rule engine?
With content-editable rules you can now setup:
- customizable promotions
- customer personalization
- rule engines for decision flows
- etc.
All of this could be theoretically done on the edge-level which would make personalization seamless and fast.
Unfortunately we can’t use the Sitecore layout personalization rules though ATM as Sitecore are not exposing layout personalization rule through GraphQL endpoint.
Hope you found this POC helpful and you’d consider using more of Sitecore rule engine in your JAMStack projects in the future (because Sitecore rules are so cool!)