@xeplr/rules 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/index.js +33 -0
- package/index.mjs +21 -0
- package/package.json +35 -0
- package/src/rules.js +220 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Xeplr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @xeplr/rules — conditional formatting, as a condition language.
|
|
2
|
+
//
|
|
3
|
+
// const { matches, OPERATORS } = require('@xeplr/rules');
|
|
4
|
+
// import { matches } from '@xeplr/rules'
|
|
5
|
+
//
|
|
6
|
+
// "When margin is under zero" is the same question whether the answer paints a
|
|
7
|
+
// bar, a table cell, a metric or an arrow. This package owns that question and
|
|
8
|
+
// nothing else — what to DO about a match belongs to whatever is drawing.
|
|
9
|
+
//
|
|
10
|
+
// A STATIC OBJECT LITERAL, not `module.exports = require(…)` and not
|
|
11
|
+
// `exports.x = …`. Both are equivalent in Node and invisible to Rollup, which
|
|
12
|
+
// analyses CommonJS statically: named imports failed at build time while
|
|
13
|
+
// working perfectly under test. This is the form a bundler can read, and it is
|
|
14
|
+
// why @xeplr/ui-charts' own barrel is written the same way.
|
|
15
|
+
var R = require('./src/rules.js');
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
// The comparisons, as DATA — so a picker can be built from them and a caller
|
|
19
|
+
// can tell which ones a column supports without a second list to keep in step.
|
|
20
|
+
OPERATORS: R.OPERATORS,
|
|
21
|
+
operator: R.operator,
|
|
22
|
+
operatorsFor: R.operatorsFor,
|
|
23
|
+
|
|
24
|
+
// The question itself. Pure, total, dependency-free.
|
|
25
|
+
matches: R.matches,
|
|
26
|
+
firstMatch: R.firstMatch,
|
|
27
|
+
resolveThen: R.resolveThen,
|
|
28
|
+
|
|
29
|
+
// Value formatting, shared for the same reason: "two decimals, in pounds" is
|
|
30
|
+
// the same instruction wherever it lands.
|
|
31
|
+
formatNumber: R.formatNumber,
|
|
32
|
+
columnIsNumeric: R.columnIsNumeric
|
|
33
|
+
};
|
package/index.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The ESM face of this package.
|
|
2
|
+
//
|
|
3
|
+
// The source is CommonJS because @xeplr/ui-charts is, and it `require`s this.
|
|
4
|
+
// But named imports from a CommonJS module are guesswork: Node's lexer and
|
|
5
|
+
// Rollup's analyser each scan the source for patterns, and both missed
|
|
6
|
+
// exports here that the other found — `columnIsNumeric` resolved in a bundle
|
|
7
|
+
// and threw under test.
|
|
8
|
+
//
|
|
9
|
+
// So the guessing is removed. `import` gets this file, which has real ESM
|
|
10
|
+
// exports; `require` gets index.js, which has real CommonJS ones. One source
|
|
11
|
+
// of truth underneath both, and no consumer has to know which it got.
|
|
12
|
+
import R from './src/rules.js'
|
|
13
|
+
|
|
14
|
+
export const OPERATORS = R.OPERATORS
|
|
15
|
+
export const operator = R.operator
|
|
16
|
+
export const operatorsFor = R.operatorsFor
|
|
17
|
+
export const matches = R.matches
|
|
18
|
+
export const firstMatch = R.firstMatch
|
|
19
|
+
export const resolveThen = R.resolveThen
|
|
20
|
+
export const formatNumber = R.formatNumber
|
|
21
|
+
export const columnIsNumeric = R.columnIsNumeric
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xeplr/rules",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Conditional formatting, as a condition language. Renderer-agnostic: it answers whether a rule matches a value and how to format one. What to DO about a match belongs to whatever is drawing.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"index.mjs",
|
|
9
|
+
"src/"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test test/*.test.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"conditional-formatting",
|
|
16
|
+
"rules",
|
|
17
|
+
"xeplr"
|
|
18
|
+
],
|
|
19
|
+
"author": "xeplr",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/Xeplr/x-rules.git"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"import": "./index.mjs",
|
|
31
|
+
"require": "./index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"module": "index.mjs"
|
|
35
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// CONDITIONAL FORMATTING, as a condition language.
|
|
2
|
+
//
|
|
3
|
+
// "When margin is under zero" is the same question whether the answer paints
|
|
4
|
+
// a bar, a table cell, a metric or an arrow. This package owns that question
|
|
5
|
+
// and nothing else: it says whether a rule MATCHES a value, and how to format
|
|
6
|
+
// one. What to DO about a match belongs to whatever is drawing — a chart sets
|
|
7
|
+
// ECharts channels, a table sets CSS, a DOM widget sets a style object, and
|
|
8
|
+
// none of those have an implementation to share.
|
|
9
|
+
//
|
|
10
|
+
// It lives on its own because the alternative addresses are all wrong. It
|
|
11
|
+
// began inside the charting package, which meant a table wanting to ask "is
|
|
12
|
+
// this less than zero" had to depend on a chart library. Moving it to the BI
|
|
13
|
+
// report engine would have made a shared package depend on a product one. So:
|
|
14
|
+
// no dependencies, imported by anything.
|
|
15
|
+
//
|
|
16
|
+
// A RULE
|
|
17
|
+
// { field, op, value, value2?, target?, series?, then: { … } }
|
|
18
|
+
//
|
|
19
|
+
// field the column to TEST — any column in the data, not only a plotted
|
|
20
|
+
// or displayed one. A bar can be sized by revenue and coloured by
|
|
21
|
+
// margin; a table row can be shaded by a column it does not show.
|
|
22
|
+
// op see OPERATORS
|
|
23
|
+
// then what to do about a match. NOT interpreted here — its vocabulary
|
|
24
|
+
// belongs to the target, which is the only thing that knows what a
|
|
25
|
+
// legal property is.
|
|
26
|
+
//
|
|
27
|
+
// ORDER MATTERS: callers apply the FIRST matching rule. That is a convention
|
|
28
|
+
// this package does not enforce, because "first" only means something once
|
|
29
|
+
// somebody has decided what the list is.
|
|
30
|
+
|
|
31
|
+
// ── the operators ────────────────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// Data, not a switch, so a UI can build a picker from them and can tell which
|
|
34
|
+
// ones a column supports without a second list to keep in step. `numeric`
|
|
35
|
+
// means the comparison only answers over numbers — offering "is more than"
|
|
36
|
+
// on a column of names is a control that can never be true.
|
|
37
|
+
|
|
38
|
+
var OPERATORS = [
|
|
39
|
+
{ key: 'lt', label: 'is less than', numeric: true },
|
|
40
|
+
{ key: 'lte', label: 'is at most', numeric: true },
|
|
41
|
+
{ key: 'gt', label: 'is more than', numeric: true },
|
|
42
|
+
{ key: 'gte', label: 'is at least', numeric: true },
|
|
43
|
+
{ key: 'between', label: 'is between', numeric: true, second: true },
|
|
44
|
+
{ key: 'eq', label: 'is' },
|
|
45
|
+
{ key: 'neq', label: 'is not' },
|
|
46
|
+
{ key: 'contains', label: 'contains' },
|
|
47
|
+
{ key: 'startsWith', label: 'starts with' },
|
|
48
|
+
{ key: 'endsWith', label: 'ends with' },
|
|
49
|
+
{ key: 'isEmpty', label: 'is blank', noValue: true },
|
|
50
|
+
{ key: 'notEmpty', label: 'is not blank', noValue: true }
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
var BY_KEY = {};
|
|
54
|
+
OPERATORS.forEach(function (o) { BY_KEY[o.key] = o; });
|
|
55
|
+
|
|
56
|
+
function operator(key) { return BY_KEY[key] || null; }
|
|
57
|
+
|
|
58
|
+
/** The operators worth offering for a column — numeric ones only where they can answer. */
|
|
59
|
+
function operatorsFor(isNumeric) {
|
|
60
|
+
return isNumeric ? OPERATORS.slice() : OPERATORS.filter(function (o) { return !o.numeric; });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── comparing ────────────────────────────────────────────────────────────
|
|
64
|
+
//
|
|
65
|
+
// Numeric where both sides genuinely are numbers, textual otherwise. That is
|
|
66
|
+
// what somebody means by "is more than" over a column of numbers stored as
|
|
67
|
+
// strings — which is how several drivers return a decimal — and by "is" over
|
|
68
|
+
// a column of department names.
|
|
69
|
+
|
|
70
|
+
function asNumber(v) {
|
|
71
|
+
if (typeof v === 'number') return isFinite(v) ? v : null;
|
|
72
|
+
if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v))) return Number(v);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function asText(v) {
|
|
77
|
+
return v === null || v === undefined ? '' : String(v).toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isBlank(v) {
|
|
81
|
+
return v === null || v === undefined || String(v).trim() === '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a rule matches a value.
|
|
86
|
+
*
|
|
87
|
+
* Pure, total, and dependency-free — it is the one piece of conditional
|
|
88
|
+
* formatting that every renderer shares, so it must be importable by all of
|
|
89
|
+
* them without dragging anything along.
|
|
90
|
+
*/
|
|
91
|
+
function matches(rule, actual) {
|
|
92
|
+
if (!rule) return false;
|
|
93
|
+
var op = rule.op;
|
|
94
|
+
if (op === 'isEmpty') return isBlank(actual);
|
|
95
|
+
if (op === 'notEmpty') return !isBlank(actual);
|
|
96
|
+
|
|
97
|
+
var a = asNumber(actual);
|
|
98
|
+
var b = asNumber(rule.value);
|
|
99
|
+
var numeric = a !== null && b !== null;
|
|
100
|
+
|
|
101
|
+
switch (op) {
|
|
102
|
+
case 'lt': return numeric && a < b;
|
|
103
|
+
case 'lte': return numeric && a <= b;
|
|
104
|
+
case 'gt': return numeric && a > b;
|
|
105
|
+
case 'gte': return numeric && a >= b;
|
|
106
|
+
case 'between': {
|
|
107
|
+
var c = asNumber(rule.value2);
|
|
108
|
+
if (a === null || b === null || c === null) return false;
|
|
109
|
+
return a >= Math.min(b, c) && a <= Math.max(b, c);
|
|
110
|
+
}
|
|
111
|
+
// Equality reads as a NUMBER when both sides are numbers, so "is 0" also
|
|
112
|
+
// matches "0.0" and the string "0" — one setting rather than three.
|
|
113
|
+
case 'eq': return numeric ? a === b : asText(actual) === asText(rule.value);
|
|
114
|
+
case 'neq': return numeric ? a !== b : asText(actual) !== asText(rule.value);
|
|
115
|
+
case 'contains': return asText(actual).indexOf(asText(rule.value)) >= 0;
|
|
116
|
+
case 'startsWith': return asText(actual).indexOf(asText(rule.value)) === 0;
|
|
117
|
+
case 'endsWith': {
|
|
118
|
+
var t = asText(actual);
|
|
119
|
+
var n = asText(rule.value);
|
|
120
|
+
return n.length <= t.length && t.lastIndexOf(n) === t.length - n.length;
|
|
121
|
+
}
|
|
122
|
+
// An operator nobody implements is not a match. Silently treating it as
|
|
123
|
+
// one would style rows for a condition that was never evaluated.
|
|
124
|
+
default: return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The first rule that matches, or null. The convention every caller follows. */
|
|
129
|
+
function firstMatch(rules, valueOf) {
|
|
130
|
+
var list = rules || [];
|
|
131
|
+
for (var i = 0; i < list.length; i++) {
|
|
132
|
+
if (matches(list[i], valueOf(list[i]))) return list[i];
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The `then` of the first rule that matches — the whole of what a DOM surface
|
|
139
|
+
* needs from this package.
|
|
140
|
+
*
|
|
141
|
+
* A chart has to compile rules into ECharts callbacks because it draws its own
|
|
142
|
+
* marks. Everything else — a table cell, a metric, a label, an arrow — is a
|
|
143
|
+
* DOM node with a style on it, and for those "which rule won, and what did it
|
|
144
|
+
* ask for" IS the applier. There is nothing more to build.
|
|
145
|
+
*
|
|
146
|
+
* `valueOf(rule)` is how the caller says where a rule's field is read from: a
|
|
147
|
+
* row for a table, the board's parameter scope for a label, the widget's one
|
|
148
|
+
* row for a metric. The condition never needs to know.
|
|
149
|
+
*
|
|
150
|
+
* Returns null when nothing matched, so a caller can tell "no rule applied"
|
|
151
|
+
* from "a rule applied and asked for nothing".
|
|
152
|
+
*/
|
|
153
|
+
function resolveThen(rules, valueOf) {
|
|
154
|
+
var hit = firstMatch(rules, valueOf);
|
|
155
|
+
return hit ? (hit.then || {}) : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── formatting a value ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A number, as a person should read it.
|
|
162
|
+
*
|
|
163
|
+
* Here rather than in a renderer because "two decimals, in pounds" is the
|
|
164
|
+
* same instruction wherever it lands, and Intl is the only sane implementation
|
|
165
|
+
* of it — currency symbols, grouping separators and negative conventions all
|
|
166
|
+
* differ by locale and are not worth reimplementing per surface.
|
|
167
|
+
*
|
|
168
|
+
* Anything that is not a number comes back null, so a caller can leave a
|
|
169
|
+
* category name alone rather than printing NaN over it.
|
|
170
|
+
*/
|
|
171
|
+
function formatNumber(value, f) {
|
|
172
|
+
if (!f) return null;
|
|
173
|
+
var n = asNumber(value);
|
|
174
|
+
if (n === null) return null;
|
|
175
|
+
var style = f.style || 'decimal';
|
|
176
|
+
var opts = { useGrouping: f.useGrouping !== false };
|
|
177
|
+
if (style === 'currency') { opts.style = 'currency'; opts.currency = f.currency || 'USD'; }
|
|
178
|
+
else if (style === 'percent') opts.style = 'percent';
|
|
179
|
+
else opts.style = 'decimal';
|
|
180
|
+
if (f.decimals != null && isFinite(Number(f.decimals))) {
|
|
181
|
+
opts.minimumFractionDigits = Number(f.decimals);
|
|
182
|
+
opts.maximumFractionDigits = Number(f.decimals);
|
|
183
|
+
}
|
|
184
|
+
var out;
|
|
185
|
+
try { out = new Intl.NumberFormat(f.locale || undefined, opts).format(n); }
|
|
186
|
+
catch (e) { out = String(n); }
|
|
187
|
+
return (f.prefix || '') + out + (f.suffix || '');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a column can answer a numeric comparison, judged from its values.
|
|
192
|
+
*
|
|
193
|
+
* From the ROWS rather than a schema, because the rows are what will actually
|
|
194
|
+
* be tested. Numeric strings count. Blanks say nothing either way — a column
|
|
195
|
+
* is not text because some of it is empty.
|
|
196
|
+
*/
|
|
197
|
+
function columnIsNumeric(rows, key, sampleSize) {
|
|
198
|
+
var sample = (rows || []).slice(0, sampleSize || 20);
|
|
199
|
+
var sawValue = false;
|
|
200
|
+
for (var i = 0; i < sample.length; i++) {
|
|
201
|
+
var v = sample[i] ? sample[i][key] : undefined;
|
|
202
|
+
if (isBlank(v)) continue;
|
|
203
|
+
sawValue = true;
|
|
204
|
+
if (asNumber(v) === null) return false;
|
|
205
|
+
}
|
|
206
|
+
// Nothing to judge on: offer everything and let the renderer report a rule
|
|
207
|
+
// that turns out not to fit.
|
|
208
|
+
return sawValue || !sample.length;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
OPERATORS: OPERATORS,
|
|
213
|
+
operator: operator,
|
|
214
|
+
operatorsFor: operatorsFor,
|
|
215
|
+
matches: matches,
|
|
216
|
+
firstMatch: firstMatch,
|
|
217
|
+
resolveThen: resolveThen,
|
|
218
|
+
formatNumber: formatNumber,
|
|
219
|
+
columnIsNumeric: columnIsNumeric
|
|
220
|
+
};
|