@schukai/monster 4.154.12 → 5.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
 
4
4
 
5
+ ## [5.0.0] - 2026-10-10
6
+
7
+
8
+
5
9
  ## [4.154.12] - 2026-10-10
6
10
 
7
11
  ### Bug Fixes
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","exports":{".":"./source/monster.mjs","./CHANGELOG.md":"./CHANGELOG.md","./LICENSE":"./LICENSE","./README.md":"./README.md","./package.json":"./package.json","./source/*":"./source/*","./test/*":"./test/*"},"files":["source","test/cases","test/util","CHANGELOG.md"],"homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"sideEffects":["./source/monster.mjs","./source/components/**","./source/i18n/**","./test/**"],"type":"module","version":"4.154.12"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","exports":{".":"./source/monster.mjs","./CHANGELOG.md":"./CHANGELOG.md","./LICENSE":"./LICENSE","./README.md":"./README.md","./package.json":"./package.json","./source/*":"./source/*","./test/*":"./test/*"},"files":["source","test/cases","test/util","CHANGELOG.md"],"homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"sideEffects":["./source/monster.mjs","./source/components/**","./source/i18n/**","./test/**"],"type":"module","version":"5.0.0"}
@@ -61,6 +61,7 @@ import { addErrorAttribute } from "./error.mjs";
61
61
 
62
62
  export {
63
63
  CustomElement,
64
+ OptionsNotInitializedError,
64
65
  initMethodSymbol,
65
66
  assembleMethodSymbol,
66
67
  attributeObserverSymbol,
@@ -69,6 +70,34 @@ export {
69
70
  updaterTransformerMethodsSymbol,
70
71
  };
71
72
 
73
+ /**
74
+ * An options API was used before the element's option observer was created.
75
+ * @summary Reports an option access before CustomElement initialization.
76
+ * @since 5.0.0
77
+ */
78
+ class OptionsNotInitializedError extends Error {
79
+ /**
80
+ * @param {string} method attempted options API method
81
+ * @param {string} component component constructor name
82
+ */
83
+ constructor(method, component) {
84
+ super(
85
+ `${component}.${method}() requires initialized options. Do not use the options API while defaults or customization are being evaluated during construction. Use initMethodSymbol for initial options, or assembly for attribute/script configuration.`,
86
+ );
87
+ this.name = "OptionsNotInitializedError";
88
+ this.method = method;
89
+ this.component = component;
90
+ }
91
+ }
92
+
93
+ function requireOptionObserver(element, method) {
94
+ const observer = element[internalSymbol];
95
+ if (observer === undefined) {
96
+ throw new OptionsNotInitializedError(method, element.constructor.name);
97
+ }
98
+ return observer;
99
+ }
100
+
72
101
  /**
73
102
  * @type {symbol}
74
103
  */
@@ -224,12 +253,15 @@ class RevisionObserver extends Observer {
224
253
  */
225
254
  class CustomElement extends HTMLElement {
226
255
  /**
227
- * A new object is created. First, the `initOptions` method is called. Here the
228
- * options can be defined in derived classes. Subsequently, the shadowRoot is initialized.
256
+ * Initializes defaults, available option attributes and customization before
257
+ * creating the option observer and calling initMethodSymbol. The options API
258
+ * must not be called while defaults or customization are being evaluated.
259
+ * JSON/script options and DOM rendering are handled later by base assembly.
229
260
  *
230
261
  * IMPORTANT: CustomControls instances are not created via the constructor, but either via a tag in the HTML or via <code>document.createElement()</code>.
231
262
  *
232
263
  * @throws {Error} the option attribute does not contain a valid JSON definition.
264
+ * @throws {OptionsNotInitializedError} an options API was called too early
233
265
  */
234
266
  constructor() {
235
267
  super();
@@ -247,9 +279,10 @@ class CustomElement extends HTMLElement {
247
279
  );
248
280
  }
249
281
 
250
- if (this.customization instanceof Map) {
282
+ const customization = this.customization;
283
+ if (customization instanceof Map) {
251
284
  const pathfinder = new Pathfinder(options);
252
- this.customization.forEach((value, key) => {
285
+ customization.forEach((value, key) => {
253
286
  if (key === undefined || key === null || key === "") {
254
287
  return;
255
288
  }
@@ -333,6 +366,7 @@ class CustomElement extends HTMLElement {
333
366
  * Unlike the defaults that expect an object, the customization is a Map.
334
367
  * This also allows overwriting individual values in a deeper structure
335
368
  * without having to redefine the entire structure and thus changing the defaults.
369
+ * Evaluated once during construction, before the options API is available.
336
370
  * @return {Map}
337
371
  */
338
372
  get customization() {
@@ -540,20 +574,17 @@ class CustomElement extends HTMLElement {
540
574
  /**
541
575
  * nested options can be specified by path `a.b.c`
542
576
  *
543
- * Before the constructor has initialized the option observer, this method
544
- * returns the supplied default value. This preserves option reads from
545
- * derived `defaults` and `customization` getters during initialization.
577
+ * Options are available from initMethodSymbol onwards. Attribute/script JSON
578
+ * is applied during assembly. The fallback only applies to initialized options.
546
579
  *
547
580
  * @param {string} path
548
581
  * @param {*} defaultValue
549
582
  * @return {*}
583
+ * @throws {OptionsNotInitializedError} options have not been initialized
550
584
  * @since 1.10.0
551
585
  */
552
586
  getOption(path, defaultValue = undefined) {
553
- const optionObserver = this[internalSymbol];
554
- if (optionObserver === undefined) {
555
- return defaultValue;
556
- }
587
+ const optionObserver = requireOptionObserver(this, "getOption");
557
588
 
558
589
  return new Pathfinder(optionObserver.getRealSubject().options).getVia(
559
590
  path,
@@ -568,12 +599,12 @@ class CustomElement extends HTMLElement {
568
599
  * @param {*} value
569
600
  * @return {CustomElement}
570
601
  * @since 1.14.0
602
+ * @throws {OptionsNotInitializedError} options have not been initialized
571
603
  */
572
604
  setOption(path, value) {
573
- new Pathfinder(this[internalSymbol].getSubject()["options"]).setVia(
574
- path,
575
- value,
576
- );
605
+ new Pathfinder(
606
+ requireOptionObserver(this, "setOption").getSubject().options,
607
+ ).setVia(path, value);
577
608
  return this;
578
609
  }
579
610
 
@@ -581,15 +612,17 @@ class CustomElement extends HTMLElement {
581
612
  * @since 1.15.0
582
613
  * @param {string|object} options
583
614
  * @return {CustomElement}
615
+ * @throws {OptionsNotInitializedError} options have not been initialized
584
616
  */
585
617
  setOptions(options) {
618
+ const optionObserver = requireOptionObserver(this, "setOptions");
586
619
  if (isString(options)) {
587
620
  options = parseOptionsJSON.call(this, options);
588
621
  }
589
622
  // 2024-01-21: remove this.defaults, otherwise it will overwrite
590
623
  // the current settings that have already been made.
591
624
  // https://gitlab.schukai.com/oss/libraries/javascript/monster/-/issues/136
592
- extend(this[internalSymbol].getSubject()["options"], options);
625
+ extend(optionObserver.getSubject().options, options);
593
626
 
594
627
  return this;
595
628
  }
@@ -156,7 +156,7 @@ function getMonsterVersion() {
156
156
  }
157
157
 
158
158
  /** Managed by the release script; keep synchronized with package.json. */
159
- monsterVersion = new Version("4.154.12");
159
+ monsterVersion = new Version("5.0.0");
160
160
 
161
161
  return monsterVersion;
162
162
  }
@@ -1232,7 +1232,7 @@ describe('DOM', function () {
1232
1232
  mocks.innerHTML = html1;
1233
1233
  })
1234
1234
 
1235
- it('should return the fallback while defaults and customization initialize options', function () {
1235
+ it('should reject option reads while defaults initialize options (#596)', function () {
1236
1236
  const htmlTAG = 'monster-testclass-early-option-576';
1237
1237
 
1238
1238
  if (!customElements.get(htmlTAG)) {
@@ -1270,13 +1270,9 @@ describe('DOM', function () {
1270
1270
  registerCustomElement(EarlyOptionComponent);
1271
1271
  }
1272
1272
 
1273
- const element = document.createElement(htmlTAG);
1274
-
1275
- expect(element.getOption('fromDefaults')).to.equal(
1276
- 'defaults-fallback',
1277
- );
1278
- expect(element.getOption('fromCustomization')).to.equal(
1279
- 'customization-fallback',
1273
+ const Constructor = customElements.get(htmlTAG);
1274
+ expect(() => new Constructor()).to.throw(
1275
+ 'getOption() requires initialized options',
1280
1276
  );
1281
1277
  });
1282
1278
 
@@ -0,0 +1,40 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { readFileSync } from "node:fs";
3
+ import { globSync } from "glob";
4
+ import { findEarlyOptions } from "../../util/early-options.mjs";
5
+
6
+ describe("Early options source contract (#596)", () => {
7
+ it("rejects synchronous helper and method calls but allows deferred callbacks", () => {
8
+ const source = `
9
+ function helper() { return this.getOption("value"); }
10
+ class Example {
11
+ get defaults() { return { value: helper.call(this), later: () => this.getOption("later") }; }
12
+ get customization() { this.configure(); return new Map(); }
13
+ configure() { this.setOptions({}); }
14
+ }`;
15
+ assert.deepEqual(
16
+ findEarlyOptions(source).map(({ phase, method }) => ({ phase, method })),
17
+ [
18
+ { phase: "defaults", method: "getOption" },
19
+ { phase: "customization", method: "setOptions" },
20
+ ],
21
+ );
22
+ assert.equal(
23
+ findEarlyOptions(
24
+ 'class Example { get defaults() { return (() => this.setOption("x", 1))(); } }',
25
+ ).length,
26
+ 1,
27
+ );
28
+ });
29
+ it("has no statically resolved early options access in library getters", () => {
30
+ const root = new URL("../../../", import.meta.url);
31
+ const files = globSync("source/**/*.mjs", { cwd: root }).sort();
32
+ assert.ok(files.length > 300);
33
+ const failures = files.flatMap((file) =>
34
+ findEarlyOptions(readFileSync(new URL(file, root), "utf8")).map(
35
+ (failure) => ({ file, ...failure }),
36
+ ),
37
+ );
38
+ assert.deepEqual(failures, []);
39
+ });
40
+ });
@@ -0,0 +1,216 @@
1
+ import { expect } from "chai";
2
+ import { initJSDOM } from "../../util/jsdom.mjs";
3
+
4
+ describe("Strict options lifecycle (#596)", function () {
5
+ let api;
6
+ let fixture;
7
+ let sequence = 0;
8
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 30));
9
+
10
+ before(async () => {
11
+ await initJSDOM({});
12
+ api = await import("../../../source/dom/customelement.mjs");
13
+ });
14
+ beforeEach(() => {
15
+ fixture = document.createElement("div");
16
+ document.getElementById("mocks").append(fixture);
17
+ });
18
+ afterEach(() => fixture.remove());
19
+
20
+ function register(Constructor) {
21
+ const tag = `monster-options-596-${++sequence}`;
22
+ customElements.define(tag, Constructor);
23
+ return tag;
24
+ }
25
+
26
+ for (const phase of ["defaults", "customization"]) {
27
+ for (const [method, args] of [
28
+ ["getOption", ["value", "fallback"]],
29
+ ["setOption", ["value", "early"]],
30
+ ["setOptions", [{ value: "early" }]],
31
+ ["setOptions", ["invalid JSON"]],
32
+ ]) {
33
+ it(`rejects ${method}(${JSON.stringify(args)}) in ${phase}`, () => {
34
+ class EarlyOptions extends api.CustomElement {}
35
+ Object.defineProperty(EarlyOptions.prototype, phase, {
36
+ get() {
37
+ return this[method](...args);
38
+ },
39
+ });
40
+ register(EarlyOptions);
41
+ let failure;
42
+ try {
43
+ new EarlyOptions();
44
+ } catch (error) {
45
+ failure = error;
46
+ }
47
+ expect(failure).to.be.instanceOf(api.OptionsNotInitializedError);
48
+ expect(failure.name).to.equal("OptionsNotInitializedError");
49
+ expect(failure.method).to.equal(method);
50
+ expect(failure.component).to.equal("EarlyOptions");
51
+ expect(failure.message).to.include("initMethodSymbol");
52
+ });
53
+ }
54
+ }
55
+
56
+ it("evaluates customization once and makes reads and writes available in init", () => {
57
+ let evaluations = 0;
58
+ class Initialized extends api.CustomElement {
59
+ get defaults() {
60
+ return { ...super.defaults, value: "default", nested: { kept: true } };
61
+ }
62
+ get customization() {
63
+ evaluations++;
64
+ return new Map([["value", "custom"]]);
65
+ }
66
+ [api.initMethodSymbol]() {
67
+ super[api.initMethodSymbol]();
68
+ expect(this.getOption("value")).to.equal("custom");
69
+ expect(this.setOption("nested.added", 1)).to.equal(this);
70
+ expect(this.setOptions('{"nested":{"other":2}}')).to.equal(this);
71
+ }
72
+ }
73
+ const element = document.createElement(register(Initialized));
74
+ expect(element).to.be.instanceOf(Initialized);
75
+ expect(evaluations).to.equal(1);
76
+ expect(element.getOption("nested")).to.deep.equal({
77
+ kept: true,
78
+ added: 1,
79
+ other: 2,
80
+ });
81
+ });
82
+
83
+ it("preserves fallback and falsy values, and propagates unrelated failures", () => {
84
+ class Options extends api.CustomElement {}
85
+ const element = document.createElement(register(Options));
86
+ for (const value of [null, false, 0, ""]) {
87
+ element.setOption("value", value);
88
+ expect(element.getOption("value", "fallback")).to.equal(value);
89
+ }
90
+ element.setOption("value", undefined);
91
+ expect(element.getOption("value", "fallback")).to.equal("fallback");
92
+ expect(element.getOption("missing.deep", "fallback")).to.equal("fallback");
93
+ expect(element.getOption("missing.deep")).to.equal(undefined);
94
+ expect(() => element.setOptions("invalid JSON")).to.throw();
95
+ const failure = new Error("property failure");
96
+ element.setOption(
97
+ "broken",
98
+ Object.defineProperty({}, "value", {
99
+ get() {
100
+ throw failure;
101
+ },
102
+ enumerable: true,
103
+ }),
104
+ );
105
+ expect(() => element.getOption("broken.value", "fallback")).to.throw(
106
+ failure,
107
+ );
108
+ });
109
+
110
+ it("preserves attribute/customization precedence on upgrade and permits deferred defaults callbacks", async () => {
111
+ class Upgraded extends api.CustomElement {
112
+ get defaults() {
113
+ return {
114
+ ...super.defaults,
115
+ value: "default",
116
+ fromAttribute: "default",
117
+ readLater: () => this.getOption("value"),
118
+ templates: { main: "<span></span>" },
119
+ };
120
+ }
121
+ get customization() {
122
+ return new Map([["value", "custom"]]);
123
+ }
124
+ [api.initMethodSymbol]() {
125
+ super[api.initMethodSymbol]();
126
+ this.initialValue = this.getOption("value");
127
+ this.initialAttribute = this.getOption("fromAttribute");
128
+ }
129
+ }
130
+ const tag = `monster-options-596-${++sequence}`;
131
+ const holder = document.createElement("div");
132
+ holder.innerHTML = `<${tag} data-monster-option-value="attribute" data-monster-option-from-attribute="present"></${tag}>`;
133
+ const element = holder.firstElementChild;
134
+ customElements.define(tag, Upgraded);
135
+ customElements.upgrade(element);
136
+ expect(element).to.be.instanceOf(Upgraded);
137
+ expect(element.initialValue).to.equal("custom");
138
+ expect(element.initialAttribute).to.equal("present");
139
+ expect(element.getOption("readLater")()).to.equal("custom");
140
+ fixture.append(element);
141
+ await tick();
142
+ expect(element.getOption("readLater")()).to.equal("attribute");
143
+ });
144
+
145
+ it("applies late attributes, JSON and script before rendering; reconnect keeps state", async () => {
146
+ let assemblies = 0;
147
+ class Configured extends api.CustomElement {
148
+ get defaults() {
149
+ return {
150
+ ...super.defaults,
151
+ value: "default",
152
+ flag: false,
153
+ count: 0,
154
+ templates: {
155
+ main: '<span data-monster-replace="path:value"></span>',
156
+ },
157
+ };
158
+ }
159
+ get customization() {
160
+ return new Map([["value", "custom"]]);
161
+ }
162
+ [api.initMethodSymbol]() {
163
+ super[api.initMethodSymbol]();
164
+ this.initialValue = this.getOption("value");
165
+ }
166
+ [api.assembleMethodSymbol]() {
167
+ assemblies++;
168
+ super[api.assembleMethodSymbol]();
169
+ this.assembledValue = this.getOption("value");
170
+ }
171
+ }
172
+ const element = document.createElement(register(Configured));
173
+ expect(element.initialValue).to.equal("custom");
174
+ element.setOption("value", "runtime-before-connect");
175
+ element.setAttribute("data-monster-option-value", "attribute");
176
+ element.setAttribute("data-monster-option-flag", "true");
177
+ element.setAttribute("data-monster-option-count", "7");
178
+ element.setAttribute("data-monster-options", '{"value":"json"}');
179
+ const script = document.createElement("script");
180
+ script.type = "application/json";
181
+ script.id = "options-script-596";
182
+ script.textContent = '{"value":"script"}';
183
+ fixture.append(script);
184
+ element.setAttribute(
185
+ "data-monster-options-selector",
186
+ "#options-script-596",
187
+ );
188
+ fixture.append(element);
189
+ await tick();
190
+ expect(element.assembledValue).to.equal("script");
191
+ expect(element.getOption("flag")).to.equal(true);
192
+ expect(element.getOption("count")).to.equal(7);
193
+ expect(element.shadowRoot.querySelector("span").textContent).to.equal(
194
+ "script",
195
+ );
196
+ element.removeAttribute("data-monster-option-value");
197
+ await tick();
198
+ element.setOption("value", "runtime");
199
+ await tick();
200
+ expect(element.shadowRoot.querySelector("span").textContent).to.equal(
201
+ "runtime",
202
+ );
203
+ element.remove();
204
+ script.textContent = '{"value":"must-not-reload"}';
205
+ fixture.append(element);
206
+ await tick();
207
+ expect(assemblies).to.equal(1);
208
+ expect(element.getOption("value")).to.equal("runtime");
209
+ element.setOption("value", "reconnected");
210
+ await tick();
211
+ expect(element.shadowRoot.querySelector("span").textContent).to.equal(
212
+ "reconnected",
213
+ );
214
+ expect(element.hasAttribute("data-monster-error")).to.equal(false);
215
+ });
216
+ });
@@ -0,0 +1,84 @@
1
+ import { parse } from "espree";
2
+
3
+ const optionMethods = new Set(["getOption", "setOption", "setOptions"]);
4
+ const functions = new Set([
5
+ "FunctionDeclaration",
6
+ "FunctionExpression",
7
+ "ArrowFunctionExpression",
8
+ ]);
9
+ const children = (node) =>
10
+ Object.values(node)
11
+ .flat()
12
+ .filter((value) => value && typeof value.type === "string");
13
+ const property = (node) =>
14
+ node.computed ? node.property?.value : node.property?.name;
15
+
16
+ // A conservative local call-graph check. Imported/dynamic dispatch is covered
17
+ // by the actual component construction tests, not claimed as statically proven.
18
+ export function findEarlyOptions(source) {
19
+ const ast = parse(source, {
20
+ ecmaVersion: "latest",
21
+ sourceType: "module",
22
+ loc: true,
23
+ });
24
+ const helpers = new Map();
25
+ const getters = [];
26
+ function collect(node, methods = new Map()) {
27
+ if (node.type === "ClassBody") {
28
+ methods = new Map(
29
+ node.body
30
+ .filter((entry) => !entry.computed && entry.value)
31
+ .map((entry) => [entry.key.name, entry.value]),
32
+ );
33
+ }
34
+ if (node.type === "FunctionDeclaration" && node.id)
35
+ helpers.set(node.id.name, node);
36
+ if (node.type === "VariableDeclarator" && functions.has(node.init?.type))
37
+ helpers.set(node.id.name, node.init);
38
+ if (
39
+ node.type === "MethodDefinition" &&
40
+ node.kind === "get" &&
41
+ ["defaults", "customization"].includes(node.key.name)
42
+ ) {
43
+ getters.push({ node, methods });
44
+ }
45
+ for (const child of children(node)) collect(child, methods);
46
+ }
47
+ collect(ast);
48
+ const failures = [];
49
+ for (const { node: getter, methods } of getters) {
50
+ const visited = new Set();
51
+ function execute(fn) {
52
+ if (!fn || visited.has(fn)) return;
53
+ visited.add(fn);
54
+ walk(fn.body);
55
+ }
56
+ function walk(node) {
57
+ if (!node || functions.has(node.type)) return; // callbacks are deferred unless invoked
58
+ if (node.type === "CallExpression") {
59
+ let callee = node.callee;
60
+ if (
61
+ callee.type === "MemberExpression" &&
62
+ ["call", "apply"].includes(property(callee))
63
+ )
64
+ callee = callee.object;
65
+ if (callee.type === "MemberExpression") {
66
+ const name = property(callee);
67
+ if (optionMethods.has(name))
68
+ failures.push({
69
+ phase: getter.key.name,
70
+ method: name,
71
+ line: node.loc.start.line,
72
+ });
73
+ if (callee.object.type === "ThisExpression")
74
+ execute(methods.get(name));
75
+ } else if (callee.type === "Identifier")
76
+ execute(helpers.get(callee.name));
77
+ else if (functions.has(callee.type)) execute(callee);
78
+ }
79
+ for (const child of children(node)) walk(child);
80
+ }
81
+ execute(getter.value);
82
+ }
83
+ return failures;
84
+ }