@panphora/sapjs 0.2.0
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/README.md +350 -0
- package/dist/sap.js +2490 -0
- package/dist/sap.min.js +6 -0
- package/package.json +52 -0
- package/src/actions.js +310 -0
- package/src/carrier.js +128 -0
- package/src/compile.js +91 -0
- package/src/control-serialize.js +85 -0
- package/src/debug.js +0 -0
- package/src/dom.js +182 -0
- package/src/errors.js +190 -0
- package/src/helpers.js +119 -0
- package/src/lint.js +229 -0
- package/src/mount.js +84 -0
- package/src/mutation-bridge.js +195 -0
- package/src/pass.js +472 -0
- package/src/platform.js +78 -0
- package/src/sap.js +178 -0
- package/src/scheduler.js +65 -0
- package/src/scope.js +265 -0
package/src/compile.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { helperBundle } from "./helpers.js";
|
|
2
|
+
|
|
3
|
+
// The frozen compile signature. These argument names are fixed forever.
|
|
4
|
+
// App-registered formats extend the `fmt` namespace object, never this list,
|
|
5
|
+
// so the source-keyed cache never invalidates.
|
|
6
|
+
const ARGS = ["state", "item", "el", "root", "fmt", "num", "sum", "count", "avg", "min", "max", "plural", "days"];
|
|
7
|
+
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
|
|
10
|
+
const STATE_REF = /\bstate\.([a-z][a-z0-9_]*)/g;
|
|
11
|
+
const ITEM_REF = /\bitem\.([a-z][a-z0-9_]*)/g;
|
|
12
|
+
|
|
13
|
+
function extractDeps(src) {
|
|
14
|
+
const state = new Set();
|
|
15
|
+
const item = new Set();
|
|
16
|
+
let m;
|
|
17
|
+
STATE_REF.lastIndex = 0;
|
|
18
|
+
while ((m = STATE_REF.exec(src))) state.add(m[1]);
|
|
19
|
+
ITEM_REF.lastIndex = 0;
|
|
20
|
+
while ((m = ITEM_REF.exec(src))) item.add(m[1]);
|
|
21
|
+
return { state, item };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Compile an expression to a function with the frozen signature.
|
|
25
|
+
// `statement` mode (for effect=) runs a statement body with no return.
|
|
26
|
+
export function compile(src, statement = false) {
|
|
27
|
+
const key = (statement ? "!" : "") + src;
|
|
28
|
+
let entry = cache.get(key);
|
|
29
|
+
if (entry) return entry;
|
|
30
|
+
if (cache.size > 512) cache.clear();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const body = statement ? `"use strict";\n${src}` : `"use strict"; return (${src});`;
|
|
34
|
+
const fn = new Function(...ARGS, body);
|
|
35
|
+
entry = { fn, src, statement, deps: extractDeps(src), error: null };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
entry = { fn: null, src, statement, deps: { state: new Set(), item: new Set() }, error: err };
|
|
38
|
+
}
|
|
39
|
+
cache.set(key, entry);
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Evaluate a compiled entry against an { state, item, el, root } context.
|
|
44
|
+
// Throws if the expression throws (callers do preserve-on-error).
|
|
45
|
+
export function run(entry, ctx) {
|
|
46
|
+
if (entry.error) throw entry.error;
|
|
47
|
+
return entry.fn(ctx.state, ctx.item, ctx.el ?? null, ctx.root, ...helperBundle());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clearCompileCache() {
|
|
51
|
+
cache.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Topologically sort calc bindings within a single scope object so a calc that
|
|
55
|
+
// reads another calc evaluates after it. Cycles throw, naming the chain.
|
|
56
|
+
export function topoSort(calcs) {
|
|
57
|
+
// calcs: array of { name, entry, ... } that share one owner object.
|
|
58
|
+
const byName = new Map();
|
|
59
|
+
for (const c of calcs) byName.set(c.name, c);
|
|
60
|
+
const out = [];
|
|
61
|
+
const state = new Map(); // name -> 0 visiting, 1 done
|
|
62
|
+
const stack = [];
|
|
63
|
+
|
|
64
|
+
function visit(c) {
|
|
65
|
+
const s = state.get(c.name);
|
|
66
|
+
if (s === 1) return;
|
|
67
|
+
if (s === 0) {
|
|
68
|
+
const cycle = [...stack.slice(stack.indexOf(c.name)), c.name].join(" -> ");
|
|
69
|
+
const err = new Error(`calc: cycle: ${cycle}`);
|
|
70
|
+
err.sapCode = "E07";
|
|
71
|
+
err.cycle = cycle;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
state.set(c.name, 0);
|
|
75
|
+
stack.push(c.name);
|
|
76
|
+
for (const dep of c.entry.deps.state) {
|
|
77
|
+
const d = byName.get(dep);
|
|
78
|
+
if (d && d !== c) visit(d);
|
|
79
|
+
}
|
|
80
|
+
for (const dep of c.entry.deps.item) {
|
|
81
|
+
const d = byName.get(dep);
|
|
82
|
+
if (d && d !== c) visit(d);
|
|
83
|
+
}
|
|
84
|
+
stack.pop();
|
|
85
|
+
state.set(c.name, 1);
|
|
86
|
+
out.push(c);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const c of calcs) visit(c);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The one place that knows how a form control's live state maps to the DOM
|
|
2
|
+
// attributes that survive serialization (cloneNode / outerHTML).
|
|
3
|
+
//
|
|
4
|
+
// Browser form state lives in JS properties (.value, .checked, .selected), not
|
|
5
|
+
// in attributes. A save serializes attributes, so that state is lost unless it
|
|
6
|
+
// is copied into attributes first. This module is shared by sapjs (carrier
|
|
7
|
+
// mirror) and hyperclayjs (persistent form input values) so the two never drift.
|
|
8
|
+
//
|
|
9
|
+
// Two phases:
|
|
10
|
+
// serializeControlToAttributes(el) LIVE, cursor-safe. Run on every edit.
|
|
11
|
+
// A textarea writes an inert `data-value` attribute, because writing its
|
|
12
|
+
// `textContent` while focused destroys the cursor; everything else writes
|
|
13
|
+
// its real attribute (the WHATWG dirty-value flag keeps the display intact).
|
|
14
|
+
// finalizeControlForSave(target, source) SAVE, on a detached clone. Reads live
|
|
15
|
+
// state from `source`, writes `target`'s attributes, and resolves a textarea
|
|
16
|
+
// `data-value` into real `textContent` so the saved HTML is clean.
|
|
17
|
+
|
|
18
|
+
function inputType(el) {
|
|
19
|
+
return (el.getAttribute("type") || "text").toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeControlToAttributes(el) {
|
|
23
|
+
const tag = el.tagName;
|
|
24
|
+
if (tag === "INPUT") {
|
|
25
|
+
const type = inputType(el);
|
|
26
|
+
if (type === "checkbox" || type === "radio") {
|
|
27
|
+
if (el.checked) el.setAttribute("checked", "");
|
|
28
|
+
else el.removeAttribute("checked");
|
|
29
|
+
} else {
|
|
30
|
+
el.setAttribute("value", el.value);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (tag === "TEXTAREA") {
|
|
35
|
+
el.setAttribute("data-value", el.value);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (tag === "SELECT") {
|
|
39
|
+
const opts = el.options;
|
|
40
|
+
for (let i = 0; i < opts.length; i++) {
|
|
41
|
+
if (opts[i].selected) opts[i].setAttribute("selected", "");
|
|
42
|
+
else opts[i].removeAttribute("selected");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function finalizeControlForSave(target, source = target) {
|
|
48
|
+
const tag = target.tagName;
|
|
49
|
+
if (tag === "INPUT") {
|
|
50
|
+
const type = inputType(target);
|
|
51
|
+
if (type === "checkbox" || type === "radio") {
|
|
52
|
+
if (source.checked) target.setAttribute("checked", "");
|
|
53
|
+
else target.removeAttribute("checked");
|
|
54
|
+
} else {
|
|
55
|
+
target.setAttribute("value", source.value);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (tag === "TEXTAREA") {
|
|
60
|
+
target.textContent = source.value;
|
|
61
|
+
target.removeAttribute("data-value");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (tag === "SELECT") {
|
|
65
|
+
const tOpts = target.options;
|
|
66
|
+
const sOpts = source.options;
|
|
67
|
+
for (let i = 0; i < tOpts.length; i++) {
|
|
68
|
+
if (sOpts[i] && sOpts[i].selected) tOpts[i].setAttribute("selected", "");
|
|
69
|
+
else tOpts[i].removeAttribute("selected");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Load-time inverse of the textarea half of serialize. A saved file can still
|
|
75
|
+
// carry a textarea's value in the cursor-safe data-value attribute (when no save
|
|
76
|
+
// step finalized it into textContent), and the browser ignores data-value when
|
|
77
|
+
// populating .value on load. Restore it so the value round-trips on reload.
|
|
78
|
+
// Other control types round-trip natively (the browser seeds .value/.checked/
|
|
79
|
+
// .selected from their attributes), so they need nothing here.
|
|
80
|
+
export function rehydrateControlFromAttributes(el) {
|
|
81
|
+
if (el.tagName !== "TEXTAREA" || !el.hasAttribute("data-value")) return;
|
|
82
|
+
if (el === el.ownerDocument.activeElement) return; // mid-edit: leave it to persist
|
|
83
|
+
el.textContent = el.getAttribute("data-value");
|
|
84
|
+
el.removeAttribute("data-value");
|
|
85
|
+
}
|
package/src/debug.js
ADDED
|
Binary file
|
package/src/dom.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// DOM walking that respects scope boundaries, plus declaration parsing and id stamping.
|
|
2
|
+
|
|
3
|
+
let idSeq = 0;
|
|
4
|
+
|
|
5
|
+
export function ensureId(el, prefix = "sap") {
|
|
6
|
+
if (!el.id) el.id = `${prefix}-${(idSeq++).toString(36)}`;
|
|
7
|
+
return el.id;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isScopeBoundary(el) {
|
|
11
|
+
return el.hasAttribute("scope") || el.hasAttribute("items") || el.hasAttribute("detail");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isInert(el) {
|
|
15
|
+
return el.hasAttribute("sap-ignore") || el.hasAttribute("template");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Walk descendants that belong to scopeEl's own scope. Stops descent at nested
|
|
19
|
+
// boundaries (scope/items/detail) and skips inert subtrees (sap-ignore/template).
|
|
20
|
+
// visit(el, isBoundary) is called for every owned element including boundaries.
|
|
21
|
+
export function walkOwned(scopeEl, visit) {
|
|
22
|
+
for (const child of scopeEl.children) descend(child);
|
|
23
|
+
function descend(el) {
|
|
24
|
+
if (el.nodeType !== 1) return;
|
|
25
|
+
if (isInert(el)) return;
|
|
26
|
+
const boundary = isScopeBoundary(el);
|
|
27
|
+
visit(el, boundary);
|
|
28
|
+
if (!boundary) for (const c of el.children) descend(c);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nearestScopeEl(el) {
|
|
33
|
+
let cur = el;
|
|
34
|
+
while (cur) {
|
|
35
|
+
if (cur.hasAttribute("sap") || cur.hasAttribute("scope") || cur.hasAttribute("item") || cur.hasAttribute("detail")) {
|
|
36
|
+
return cur;
|
|
37
|
+
}
|
|
38
|
+
cur = cur.parentElement;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function nearestAppEl(el) {
|
|
44
|
+
let cur = el;
|
|
45
|
+
while (cur) {
|
|
46
|
+
if (cur.hasAttribute && cur.hasAttribute("sap")) return cur;
|
|
47
|
+
cur = cur.parentElement;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function nearestItemEl(el) {
|
|
53
|
+
let cur = el;
|
|
54
|
+
while (cur) {
|
|
55
|
+
if (cur.hasAttribute("detail")) return cur; // a detail panel resolves like a row
|
|
56
|
+
if (cur.hasAttribute("item")) return cur;
|
|
57
|
+
cur = cur.parentElement;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isInsideDetail(el) {
|
|
63
|
+
let cur = el;
|
|
64
|
+
while (cur) {
|
|
65
|
+
if (cur.hasAttribute("detail")) return cur;
|
|
66
|
+
cur = cur.parentElement;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ownedBind(scopeEl, field) {
|
|
72
|
+
let found = null;
|
|
73
|
+
walkOwned(scopeEl, (el) => {
|
|
74
|
+
if (!found && el.getAttribute("bind") === field) found = el;
|
|
75
|
+
});
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ownedItems(scopeEl, name) {
|
|
80
|
+
let found = null;
|
|
81
|
+
walkOwned(scopeEl, (el) => {
|
|
82
|
+
if (!found && el.getAttribute("items") === name) found = el;
|
|
83
|
+
});
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function rowsOf(listEl) {
|
|
88
|
+
return [...listEl.children].filter((c) => c.hasAttribute("item") && !c.hasAttribute("template"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function templateOf(listEl) {
|
|
92
|
+
return [...listEl.children].find((c) => c.hasAttribute("item") && c.hasAttribute("template")) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseStateDecl(str) {
|
|
96
|
+
const out = [];
|
|
97
|
+
if (!str) return out;
|
|
98
|
+
for (const raw of str.trim().split(/\s+/)) {
|
|
99
|
+
if (!raw) continue;
|
|
100
|
+
let token = raw;
|
|
101
|
+
let def;
|
|
102
|
+
const eq = token.indexOf("=");
|
|
103
|
+
if (eq >= 0) {
|
|
104
|
+
def = token.slice(eq + 1);
|
|
105
|
+
token = token.slice(0, eq);
|
|
106
|
+
}
|
|
107
|
+
const parts = token.split(":");
|
|
108
|
+
const name = parts[0];
|
|
109
|
+
let type = "string";
|
|
110
|
+
let transient = false;
|
|
111
|
+
for (const p of parts.slice(1)) {
|
|
112
|
+
if (p === "num") type = "num";
|
|
113
|
+
else if (p === "bool") type = "bool";
|
|
114
|
+
else if (p === "transient") transient = true;
|
|
115
|
+
}
|
|
116
|
+
out.push({ name, type, default: def, transient });
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseTyped(value, type) {
|
|
122
|
+
if (type === "num") {
|
|
123
|
+
if (value == null || value === "") return 0;
|
|
124
|
+
const n = Number(value);
|
|
125
|
+
return Number.isNaN(n) ? 0 : n;
|
|
126
|
+
}
|
|
127
|
+
if (type === "bool") return value === "true" || value === "" || value === "1";
|
|
128
|
+
return value == null ? "" : value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function serializeTyped(value, type) {
|
|
132
|
+
if (type === "bool") return value ? "true" : "false";
|
|
133
|
+
return value == null ? "" : String(value);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Transient state fields (state="x:transient") live on a runtime-only property,
|
|
137
|
+
// never an attribute, so they drive the app but are stripped from the saved DOM.
|
|
138
|
+
// The value attribute is removed on read; the state= declaration itself stays.
|
|
139
|
+
export function readTransient(el, decl) {
|
|
140
|
+
const store = el._sapTransient || (el._sapTransient = {});
|
|
141
|
+
if (!(decl.name in store)) {
|
|
142
|
+
let raw = el.getAttribute(decl.name);
|
|
143
|
+
if (raw == null) raw = decl.default != null ? decl.default : decl.type === "num" ? "0" : decl.type === "bool" ? "false" : "";
|
|
144
|
+
store[decl.name] = parseTyped(raw, decl.type);
|
|
145
|
+
}
|
|
146
|
+
if (el.hasAttribute(decl.name)) el.removeAttribute(decl.name);
|
|
147
|
+
return store[decl.name];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function writeTransient(el, decl, value) {
|
|
151
|
+
(el._sapTransient || (el._sapTransient = {}))[decl.name] = value;
|
|
152
|
+
if (el.hasAttribute(decl.name)) el.removeAttribute(decl.name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Find the list element a path points at, resolving nearest-scope-first.
|
|
156
|
+
// "cards" -> nearest owned items=cards; "doing.cards" -> [scope=doing] > [items=cards].
|
|
157
|
+
export function resolveListEl(appRoot, fromEl, path) {
|
|
158
|
+
const segs = String(path).split(".");
|
|
159
|
+
const listName = segs[segs.length - 1];
|
|
160
|
+
if (segs.length === 1) {
|
|
161
|
+
const up = findItemsUp(fromEl, listName);
|
|
162
|
+
if (up) return up;
|
|
163
|
+
if (appRoot.getAttribute("items") === listName) return appRoot;
|
|
164
|
+
return appRoot.querySelector(`[items="${listName}"]`);
|
|
165
|
+
}
|
|
166
|
+
const scopeName = segs[segs.length - 2];
|
|
167
|
+
const scopeEl = [...appRoot.querySelectorAll(`[scope="${scopeName}"]`)].find((s) =>
|
|
168
|
+
ownedItems(s, listName)
|
|
169
|
+
);
|
|
170
|
+
return scopeEl ? ownedItems(scopeEl, listName) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findItemsUp(fromEl, name) {
|
|
174
|
+
let scope = nearestScopeEl(fromEl);
|
|
175
|
+
while (scope) {
|
|
176
|
+
if (scope.getAttribute("items") === name) return scope; // the scope element is itself the list
|
|
177
|
+
const found = ownedItems(scope, name);
|
|
178
|
+
if (found) return found;
|
|
179
|
+
scope = nearestScopeEl(scope.parentElement);
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Error grammar, the E-code registry, the teaching dictionary, beacons, and
|
|
2
|
+
// per-app diagnostics that drive the green line and Sap.report().
|
|
3
|
+
|
|
4
|
+
export const REGISTRY = {
|
|
5
|
+
E01: { slug: "foreign-dialect", behavior: "HALT" },
|
|
6
|
+
E02: { slug: "moustaches-in-text", behavior: "HALT" },
|
|
7
|
+
E04: { slug: "duplicate-declaration", behavior: "HALT" },
|
|
8
|
+
E05: { slug: "reserved-name", behavior: "HALT" },
|
|
9
|
+
E06: { slug: "global-html-name", behavior: "HALT" },
|
|
10
|
+
E07: { slug: "calc-cycle", behavior: "ERR" },
|
|
11
|
+
E08: { slug: "expression-syntax", behavior: "HALT" },
|
|
12
|
+
E10: { slug: "orphan-item", behavior: "HALT" },
|
|
13
|
+
E12: { slug: "unknown-state-key", behavior: "ERR" },
|
|
14
|
+
E15: { slug: "write-to-undeclared-key", behavior: "ERR" },
|
|
15
|
+
E16: { slug: "write-to-computed-field", behavior: "ERR" },
|
|
16
|
+
E17: { slug: "unresolvable-action-target", behavior: "HALT" },
|
|
17
|
+
E18: { slug: "paint-on-form-control", behavior: "HALT" },
|
|
18
|
+
E20: { slug: "bind-on-non-control", behavior: "HALT" },
|
|
19
|
+
E22: { slug: "format-type", behavior: "ERR" },
|
|
20
|
+
E24: { slug: "runtime-expression-throw", behavior: "ERR" },
|
|
21
|
+
E26: { slug: "circuit-breaker", behavior: "HALT" },
|
|
22
|
+
E30: { slug: "attr-on-bound-control", behavior: "HALT" },
|
|
23
|
+
E31: { slug: "password-without-transient", behavior: "HALT" },
|
|
24
|
+
E32: { slug: "file-bind", behavior: "HALT" },
|
|
25
|
+
E33: { slug: "dialog-state-open", behavior: "HALT" },
|
|
26
|
+
E34: { slug: "transient-persist-collision", behavior: "HALT" },
|
|
27
|
+
W03: { slug: "unknown-colon-attribute", behavior: "WARN" },
|
|
28
|
+
W04: { slug: "expression-in-when", behavior: "WARN" },
|
|
29
|
+
W30: { slug: "nonzero-mount-writes", behavior: "WARN" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const NATIVE_BOOLEANS = [
|
|
33
|
+
"disabled", "readonly", "required", "checked", "selected",
|
|
34
|
+
"multiple", "open", "inert", "autofocus", "novalidate", "hidden",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export const HTML_GLOBALS = new Set([
|
|
38
|
+
"title", "style", "hidden", "lang", "dir", "translate", "class", "id",
|
|
39
|
+
"nonce", "autofocus", "tabindex", "contenteditable", "spellcheck",
|
|
40
|
+
"draggable", "role", "slot", "part", "name", "value", "type",
|
|
41
|
+
"itemscope", "itemtype", "itemid", "itemprop", "itemref",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export const RESERVED = new Set([
|
|
45
|
+
"sap", "scope", "items", "item", "template", "bind", "calc", "text", "show",
|
|
46
|
+
"attr", "set", "trigger", "move", "sort", "detail", "effect", "invalid",
|
|
47
|
+
"confirm", "transient", "default", "persist", "sortable",
|
|
48
|
+
// editmode is a hyperclayjs platform prefix (editmode:onclick), reserved so a
|
|
49
|
+
// co-loaded admin attribute never collides with a Sap state field name.
|
|
50
|
+
"editmode",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
export function cssPath(el) {
|
|
54
|
+
if (!el || el.nodeType !== 1) return "?";
|
|
55
|
+
if (el.id) return `${el.tagName.toLowerCase()}#${el.id}`;
|
|
56
|
+
const parts = [];
|
|
57
|
+
let cur = el;
|
|
58
|
+
let depth = 0;
|
|
59
|
+
while (cur && cur.nodeType === 1 && depth < 4) {
|
|
60
|
+
let seg = cur.tagName.toLowerCase();
|
|
61
|
+
if (cur.hasAttribute("sap")) seg += "[sap]";
|
|
62
|
+
else if (cur.parentElement) {
|
|
63
|
+
const sibs = [...cur.parentElement.children].filter((c) => c.tagName === cur.tagName);
|
|
64
|
+
if (sibs.length > 1) seg += `:nth-of-type(${sibs.indexOf(cur) + 1})`;
|
|
65
|
+
}
|
|
66
|
+
parts.unshift(seg);
|
|
67
|
+
if (cur.hasAttribute("sap")) break;
|
|
68
|
+
cur = cur.parentElement;
|
|
69
|
+
depth++;
|
|
70
|
+
}
|
|
71
|
+
return parts.join(" > ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Translate a foreign-dialect attribute to its Sap spelling, or null if unknown.
|
|
75
|
+
export function teachForAttr(name) {
|
|
76
|
+
const n = name.toLowerCase();
|
|
77
|
+
if (/^x-text$|^x-html$/.test(n)) return `use text="state.x" (Sap paints text only)`;
|
|
78
|
+
if (/^x-show$|^v-show$|^v-if$/.test(n)) return `use show="expr" (toggles native hidden)`;
|
|
79
|
+
if (/^x-model$|^v-model$/.test(n)) return `use bind="field"`;
|
|
80
|
+
if (/^@|^x-on:|^v-on:|^on:/.test(n)) return `use native onclick + Sap(this) (no event directive)`;
|
|
81
|
+
if (/^:|^x-bind:class$|^v-bind:class$/.test(n)) return `use class:NAME="expr" or attr:NAME="expr"`;
|
|
82
|
+
if (/^x-for$|^v-for$/.test(n)) return `use items="name" on the container and item template on the row`;
|
|
83
|
+
if (/^x-data$/.test(n)) return `declare state with state= / bind / items (no JS state object)`;
|
|
84
|
+
if (/^x-init$/.test(n)) return `use a <script> or onclick + Sap(this)`;
|
|
85
|
+
if (/^bind:/.test(n)) return `use bind="field"; one-way paint is attr:NAME="expr"`;
|
|
86
|
+
if (/^x-/.test(n) || /^v-/.test(n)) return `this is a foreign-dialect attribute; Sap has no x-/v- attributes`;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class HaltError extends Error {
|
|
91
|
+
constructor(code, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.sapCode = code;
|
|
94
|
+
this.isHalt = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Per-app diagnostics collector.
|
|
99
|
+
export class Diagnostics {
|
|
100
|
+
constructor(appName) {
|
|
101
|
+
this.appName = appName;
|
|
102
|
+
this.errors = [];
|
|
103
|
+
this.warnings = [];
|
|
104
|
+
this.halted = false;
|
|
105
|
+
this.haltReason = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
block(code, el, info = {}) {
|
|
109
|
+
const slug = (REGISTRY[code] || {}).slug || "error";
|
|
110
|
+
const rec = {
|
|
111
|
+
code,
|
|
112
|
+
slug,
|
|
113
|
+
el: cssPath(el),
|
|
114
|
+
attr: info.attr || null,
|
|
115
|
+
expr: info.expr || null,
|
|
116
|
+
key: info.key || null,
|
|
117
|
+
problem: info.problem || "",
|
|
118
|
+
didYouMean: info.didYouMean || null,
|
|
119
|
+
fix: info.fix || null,
|
|
120
|
+
phase: info.phase || "runtime",
|
|
121
|
+
};
|
|
122
|
+
const out = ["\n" + (code[0] === "W" ? "sap ⚠ " : "sap ✗ ") + code + " " + slug];
|
|
123
|
+
out.push(` at ${rec.el}${rec.attr ? ` — ${rec.attr}="${rec.expr ?? ""}"` : ""}`);
|
|
124
|
+
if (rec.problem) out.push(` problem: ${rec.problem}`);
|
|
125
|
+
if (rec.didYouMean) out.push(` did you mean: ${rec.didYouMean}`);
|
|
126
|
+
if (rec.fix) out.push(` fix: ${rec.fix}`);
|
|
127
|
+
console.error(out.join("\n"));
|
|
128
|
+
return rec;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
error(code, el, info = {}) {
|
|
132
|
+
const rec = this.block(code, el, info);
|
|
133
|
+
this.errors.push(rec);
|
|
134
|
+
return rec;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
warn(code, el, info = {}) {
|
|
138
|
+
const rec = this.block(code, el, info);
|
|
139
|
+
this.warnings.push(rec);
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
report() {
|
|
144
|
+
return {
|
|
145
|
+
ok: this.errors.length === 0 && !this.halted,
|
|
146
|
+
app: this.appName,
|
|
147
|
+
halted: this.halted,
|
|
148
|
+
haltReason: this.haltReason,
|
|
149
|
+
errors: this.errors.slice(),
|
|
150
|
+
warnings: this.warnings.slice(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function setBeacon(el, code) {
|
|
156
|
+
if (el && el.getAttribute("sap-error") !== code) el.setAttribute("sap-error", code);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function clearBeacon(el) {
|
|
160
|
+
if (el && el.hasAttribute("sap-error")) el.removeAttribute("sap-error");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Levenshtein-based did-you-mean against a set of candidate names.
|
|
164
|
+
export function didYouMean(name, candidates) {
|
|
165
|
+
let best = null;
|
|
166
|
+
let bestD = 3;
|
|
167
|
+
for (const c of candidates) {
|
|
168
|
+
const d = lev(name, c);
|
|
169
|
+
if (d < bestD) {
|
|
170
|
+
bestD = d;
|
|
171
|
+
best = c;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return best;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function lev(a, b) {
|
|
178
|
+
const m = a.length;
|
|
179
|
+
const n = b.length;
|
|
180
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...new Array(n).fill(0)]);
|
|
181
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
182
|
+
for (let i = 1; i <= m; i++)
|
|
183
|
+
for (let j = 1; j <= n; j++)
|
|
184
|
+
dp[i][j] = Math.min(
|
|
185
|
+
dp[i - 1][j] + 1,
|
|
186
|
+
dp[i][j - 1] + 1,
|
|
187
|
+
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
188
|
+
);
|
|
189
|
+
return dp[m][n];
|
|
190
|
+
}
|
package/src/helpers.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The frozen helper set and the format registry.
|
|
2
|
+
// These are passed into every compiled expression as the tail arguments.
|
|
3
|
+
|
|
4
|
+
export function num(v) {
|
|
5
|
+
if (typeof v === "number") return v;
|
|
6
|
+
if (v == null || v === "") return 0;
|
|
7
|
+
const n = Number(typeof v === "string" ? v.replace(/[$,%\s]/g, "") : v);
|
|
8
|
+
return Number.isNaN(n) ? 0 : n;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function values(rows, k) {
|
|
12
|
+
if (!Array.isArray(rows)) return [];
|
|
13
|
+
if (k == null) return rows;
|
|
14
|
+
return rows.map((r) => (typeof k === "function" ? k(r) : r == null ? undefined : r[k]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sum(rows, k) {
|
|
18
|
+
return values(rows, k).reduce((a, v) => a + num(v), 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function count(rows, pred) {
|
|
22
|
+
if (!Array.isArray(rows)) return 0;
|
|
23
|
+
if (pred == null) return rows.length;
|
|
24
|
+
return rows.reduce((a, r) => a + (pred(r) ? 1 : 0), 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function avg(rows, k) {
|
|
28
|
+
const v = values(rows, k);
|
|
29
|
+
if (!v.length) return 0;
|
|
30
|
+
return sum(rows, k) / v.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function min(rows, k) {
|
|
34
|
+
const v = values(rows, k).filter((x) => x != null && x !== "");
|
|
35
|
+
if (!v.length) return 0;
|
|
36
|
+
if (v.every((x) => typeof x === "number" || !Number.isNaN(Number(x)))) {
|
|
37
|
+
return Math.min(...v.map(Number));
|
|
38
|
+
}
|
|
39
|
+
return v.reduce((a, b) => (String(a) <= String(b) ? a : b));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function max(rows, k) {
|
|
43
|
+
const v = values(rows, k).filter((x) => x != null && x !== "");
|
|
44
|
+
if (!v.length) return 0;
|
|
45
|
+
if (v.every((x) => typeof x === "number" || !Number.isNaN(Number(x)))) {
|
|
46
|
+
return Math.max(...v.map(Number));
|
|
47
|
+
}
|
|
48
|
+
return v.reduce((a, b) => (String(a) >= String(b) ? a : b));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function plural(n, singular, pluralForm) {
|
|
52
|
+
const word = num(n) === 1 ? singular : pluralForm != null ? pluralForm : singular + "s";
|
|
53
|
+
return `${n} ${word}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function days(a, b) {
|
|
57
|
+
const da = new Date(a);
|
|
58
|
+
const db = new Date(b);
|
|
59
|
+
if (Number.isNaN(+da) || Number.isNaN(+db)) return 0;
|
|
60
|
+
return Math.round((db - da) / 86400000);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function n(v) {
|
|
64
|
+
return typeof v === "number" ? v : Number(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The format registry. Extensible via Sap.formats.foo = fn.
|
|
68
|
+
export const formats = {
|
|
69
|
+
usd: (v) => "$" + n(v).toLocaleString("en-US", { maximumFractionDigits: 0 }),
|
|
70
|
+
usd2: (v) =>
|
|
71
|
+
"$" + n(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
|
72
|
+
pct: (v) => Math.round(n(v)) + "%",
|
|
73
|
+
pct1: (v) => n(v).toFixed(1) + "%",
|
|
74
|
+
int: (v) => Math.round(n(v)).toLocaleString("en-US"),
|
|
75
|
+
num: (v) => n(v).toLocaleString("en-US"),
|
|
76
|
+
num2: (v) =>
|
|
77
|
+
n(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
|
78
|
+
compact: (v) => n(v).toLocaleString("en-US", { notation: "compact" }),
|
|
79
|
+
date: (v) => {
|
|
80
|
+
const d = new Date(v);
|
|
81
|
+
return Number.isNaN(+d) ? String(v) : d.toLocaleDateString("en-US");
|
|
82
|
+
},
|
|
83
|
+
clock: (v) => {
|
|
84
|
+
const ms = n(v);
|
|
85
|
+
const total = Math.floor(ms / 100);
|
|
86
|
+
const tenths = total % 10;
|
|
87
|
+
const secs = Math.floor(total / 10) % 60;
|
|
88
|
+
const mins = Math.floor(total / 600);
|
|
89
|
+
return `${mins}:${String(secs).padStart(2, "0")}.${tenths}`;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const NUMERIC = new Set(["usd", "usd2", "pct", "pct1", "int", "num", "num2", "compact", "clock"]);
|
|
94
|
+
|
|
95
|
+
// Apply a named format. Numeric formats throw a tagged E22 error on non-finite input
|
|
96
|
+
// so paint can fail loudly rather than serialize "NaN".
|
|
97
|
+
export function applyFormat(name, v) {
|
|
98
|
+
if (!name) return v == null ? "" : String(v);
|
|
99
|
+
const f = formats[name];
|
|
100
|
+
if (typeof f !== "function") {
|
|
101
|
+
const e = new Error(`unknown format "${name}"`);
|
|
102
|
+
e.sapCode = "E22";
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
if (NUMERIC.has(name)) {
|
|
106
|
+
const x = typeof v === "number" ? v : Number(v);
|
|
107
|
+
if (!Number.isFinite(x)) {
|
|
108
|
+
const e = new Error(`format "${name}" received ${typeof v === "string" ? `"${v}"` : v}`);
|
|
109
|
+
e.sapCode = "E22";
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return f(v);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The bundle handed to every compiled expression, in frozen argument order.
|
|
117
|
+
export function helperBundle() {
|
|
118
|
+
return [formats, num, sum, count, avg, min, max, plural, days];
|
|
119
|
+
}
|