@quietsapa/qsl 0.1.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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Built-in trigger handlers.
3
+ *
4
+ * A trigger handler receives the raw trigger option and the flow/process it
5
+ * belongs to, and returns a function `(cb) => void` that invokes `cb` once the
6
+ * trigger fires. Returning `null` means "not my trigger" and lets the next
7
+ * registered handler try.
8
+ */
9
+
10
+ /**
11
+ * Read the argument part of a prefixed trigger option.
12
+ *
13
+ * Uses `slice` rather than `split(':')` so that arguments containing colons
14
+ * (CSS pseudo-classes, media queries, URLs) survive intact.
15
+ *
16
+ * @param {string} opt - Full trigger option, e.g. `hover:.btn:first-child`.
17
+ * @param {string} prefix - Prefix including the colon, e.g. `hover:`.
18
+ * @returns {string} The argument, e.g. `.btn:first-child`.
19
+ */
20
+ const arg = (opt, prefix) => opt.slice(prefix.length);
21
+
22
+ /**
23
+ * Check whether an option is a string starting with the given prefix.
24
+ *
25
+ * @param {*} opt - Trigger option.
26
+ * @param {string} prefix - Prefix including the colon.
27
+ * @returns {boolean}
28
+ */
29
+ const isPrefixed = (opt, prefix) => typeof opt === 'string' && opt.startsWith(prefix);
30
+
31
+ /**
32
+ * Resolve an element now, or once it is inserted into the document.
33
+ *
34
+ * `appears:` and `visible:` may reference elements that are rendered later, so
35
+ * a plain `querySelector` at registration time is not enough.
36
+ *
37
+ * @param {string} selector - CSS selector.
38
+ * @param {Function} cb - Called with the element once it exists.
39
+ * @returns {void}
40
+ */
41
+ const whenElement = (selector, cb) => {
42
+ let el = null;
43
+ try {
44
+ el = document.querySelector(selector);
45
+ } catch (e) {
46
+ /* Invalid selector: fail open so the flow is never stuck. */
47
+ cb(null);
48
+ return;
49
+ }
50
+
51
+ if (el) {
52
+ cb(el);
53
+ return;
54
+ }
55
+
56
+ if (typeof MutationObserver !== 'function') {
57
+ cb(null);
58
+ return;
59
+ }
60
+
61
+ const root = document.body || document.documentElement;
62
+ if (!root) {
63
+ cb(null);
64
+ return;
65
+ }
66
+
67
+ const observer = new MutationObserver(() => {
68
+ const found = document.querySelector(selector);
69
+ if (found) {
70
+ observer.disconnect();
71
+ cb(found);
72
+ }
73
+ });
74
+ observer.observe(root, { childList: true, subtree: true });
75
+ };
76
+
77
+ /**
78
+ * `load` - fires on the window load event.
79
+ * @param {Object} QSL
80
+ */
81
+ export function loadTrigger(QSL) {
82
+ QSL.triggerHandlers.add(function (opt) {
83
+ if (opt !== 'load') return null;
84
+ return (cb) => {
85
+ if (document.readyState === 'complete') {
86
+ cb();
87
+ } else {
88
+ window.addEventListener('load', () => cb(), { once: true });
89
+ }
90
+ };
91
+ });
92
+ }
93
+
94
+ /**
95
+ * `idle` - fires when the browser is idle.
96
+ * @param {Object} QSL
97
+ */
98
+ export function idleTrigger(QSL) {
99
+ QSL.triggerHandlers.add(function (opt) {
100
+ if (opt !== 'idle') return null;
101
+ return (cb) => {
102
+ if (typeof window.requestIdleCallback === 'function') {
103
+ window.requestIdleCallback(() => cb());
104
+ } else {
105
+ setTimeout(() => cb(), 200);
106
+ }
107
+ };
108
+ });
109
+ }
110
+
111
+ /**
112
+ * `domready` - fires on DOMContentLoaded.
113
+ *
114
+ * `interactive` means DOMContentLoaded has already fired, so a listener added
115
+ * at that point would never run.
116
+ *
117
+ * @param {Object} QSL
118
+ */
119
+ export function domReadyTrigger(QSL) {
120
+ QSL.triggerHandlers.add(function (opt) {
121
+ if (opt !== 'domready') return null;
122
+ return (cb) => {
123
+ if (document.readyState === 'interactive' || document.readyState === 'complete') {
124
+ cb();
125
+ } else {
126
+ document.addEventListener('DOMContentLoaded', () => cb(), { once: true });
127
+ }
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * `delay:<ms>` - fires after a timeout.
134
+ * @param {Object} QSL
135
+ */
136
+ export function delayTrigger(QSL) {
137
+ QSL.triggerHandlers.add(function (opt) {
138
+ if (!isPrefixed(opt, 'delay:')) return null;
139
+ const ms = parseInt(arg(opt, 'delay:'), 10);
140
+ return (cb) => setTimeout(cb, Number.isFinite(ms) && ms > 0 ? ms : 0);
141
+ });
142
+ }
143
+
144
+ /**
145
+ * `hover:<selector>` - fires when the pointer enters the element.
146
+ * @param {Object} QSL
147
+ */
148
+ export function hoverTrigger(QSL) {
149
+ QSL.triggerHandlers.add(function (opt) {
150
+ if (!isPrefixed(opt, 'hover:')) return null;
151
+ const selector = arg(opt, 'hover:');
152
+ return (cb) => {
153
+ whenElement(selector, (el) => {
154
+ if (!el) {
155
+ cb();
156
+ return;
157
+ }
158
+ el.addEventListener('mouseenter', () => cb(), { once: true, passive: true });
159
+ });
160
+ };
161
+ });
162
+ }
163
+
164
+ /**
165
+ * `visible:<selector>` - fires when the element intersects the viewport.
166
+ * @param {Object} QSL
167
+ */
168
+ export function visibleTrigger(QSL) {
169
+ QSL.triggerHandlers.add(function (opt) {
170
+ if (!isPrefixed(opt, 'visible:')) return null;
171
+ const selector = arg(opt, 'visible:');
172
+ return (cb) => {
173
+ whenElement(selector, (el) => {
174
+ if (!el || typeof IntersectionObserver !== 'function') {
175
+ cb();
176
+ return;
177
+ }
178
+ const observer = new IntersectionObserver((entries) => {
179
+ for (const entry of entries) {
180
+ if (entry.isIntersecting) {
181
+ observer.disconnect();
182
+ cb();
183
+ return;
184
+ }
185
+ }
186
+ });
187
+ observer.observe(el);
188
+ });
189
+ };
190
+ });
191
+ }
192
+
193
+ /**
194
+ * `appears:<selector>` - fires when the element is inserted into the DOM.
195
+ * @param {Object} QSL
196
+ */
197
+ export function appearsTrigger(QSL) {
198
+ QSL.triggerHandlers.add(function (opt) {
199
+ if (!isPrefixed(opt, 'appears:')) return null;
200
+ const selector = arg(opt, 'appears:');
201
+ return (cb) => whenElement(selector, () => cb());
202
+ });
203
+ }
204
+
205
+ /**
206
+ * `media:<query>` - fires when the media query matches, now or later.
207
+ * @param {Object} QSL
208
+ */
209
+ export function mediaQueryTrigger(QSL) {
210
+ QSL.triggerHandlers.add(function (opt, o) {
211
+ if (!isPrefixed(opt, 'media:')) return null;
212
+ const query = arg(opt, 'media:');
213
+ return (cb) => {
214
+ if (!query.length || typeof window.matchMedia !== 'function') {
215
+ if (o) o.skipped = true;
216
+ cb();
217
+ return;
218
+ }
219
+ const mql = window.matchMedia(query);
220
+ if (mql.matches) {
221
+ cb();
222
+ return;
223
+ }
224
+ const handler = (e) => {
225
+ if (!e.matches) return;
226
+ mql.removeEventListener('change', handler);
227
+ cb();
228
+ };
229
+ mql.addEventListener('change', handler);
230
+ };
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Register every built-in trigger handler.
236
+ * @param {Object} QSL
237
+ */
238
+ export default function (QSL) {
239
+ loadTrigger(QSL);
240
+ idleTrigger(QSL);
241
+ domReadyTrigger(QSL);
242
+ delayTrigger(QSL);
243
+ hoverTrigger(QSL);
244
+ visibleTrigger(QSL);
245
+ appearsTrigger(QSL);
246
+ mediaQueryTrigger(QSL);
247
+ }
@@ -0,0 +1,7 @@
1
+ import core from '../core.js';
2
+
3
+ import { Script } from '../types.js';
4
+
5
+ core
6
+ .registerTypes([Script])
7
+ .init();
@@ -0,0 +1,34 @@
1
+ import core from '../core.js';
2
+
3
+ import { Script, InlineScript, InlineStyle, Stylesheet, Pixel, Shadow, HTML } from '../types.js';
4
+
5
+ import { mediaQueryCondition, languageCondition, timezoneCondition, urlCondition, userAgentCondition } from '../plugins/conditions.js';
6
+ import { loadTrigger, idleTrigger, domReadyTrigger, delayTrigger, hoverTrigger, visibleTrigger, appearsTrigger, mediaQueryTrigger } from '../plugins/triggers.js';
7
+
8
+ import logger from '../plugins/logger.js';
9
+ import events from '../plugins/events.js';
10
+ import circ from '../plugins/circ.js';
11
+
12
+ core
13
+ .registerTypes([Script, InlineScript, InlineStyle, Stylesheet, Pixel, Shadow, HTML])
14
+
15
+ .use(logger)
16
+ .use(events)
17
+ .use(circ)
18
+
19
+ .use(mediaQueryCondition)
20
+ .use(languageCondition)
21
+ .use(timezoneCondition)
22
+ .use(urlCondition)
23
+ .use(userAgentCondition)
24
+
25
+ .use(loadTrigger)
26
+ .use(idleTrigger)
27
+ .use(domReadyTrigger)
28
+ .use(delayTrigger)
29
+ .use(hoverTrigger)
30
+ .use(visibleTrigger)
31
+ .use(appearsTrigger)
32
+ .use(mediaQueryTrigger)
33
+
34
+ .init();
package/src/types.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Get cache-bypass suffix for a URL.
3
+ */
4
+ const bypassSuffix = (source, bypassCache) =>{
5
+ return bypassCache ? ( ( source.includes('?') ? '&' : '?') + Date.now() ) : '';
6
+ }
7
+
8
+ /**
9
+ * Log a message.
10
+ */
11
+ const log = (detail) => {
12
+ window.dispatchEvent(new CustomEvent('QSL:log', { detail }));
13
+ }
14
+
15
+ /**
16
+ * Log an error.
17
+ */
18
+ const error = (detail) => {
19
+ window.dispatchEvent(new CustomEvent('QSL:error', { detail }));
20
+ }
21
+
22
+ /**
23
+ * Render an element.
24
+ */
25
+ const render = (config, callbacks = {}) => {
26
+ return new Promise(async (resolve) => {
27
+ const { flowId, tag, id, delay, data, onBeforeStart, onComplete, onError, footer, dom, onElement, onCustomResolve } = config;
28
+ if (!flowId || !tag || !id) {
29
+ resolve();
30
+ return;
31
+ }
32
+ try {
33
+ if (onBeforeStart) await onBeforeStart(config);
34
+ log({ tag, type: 'PROCESS_STARTED', config });
35
+ if (delay) await new Promise(res => setTimeout(res, delay));
36
+ let el = document.createElement(tag);
37
+ onElement?.(el, config);
38
+
39
+ callbacks.registerProcessElement?.(el, config);
40
+
41
+ if (data && typeof data === 'object') {
42
+ for (const [key, value] of Object.entries(data)) {
43
+ el.setAttribute(`data-${key.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/[^a-z0-9_-]/g, '').replace(/^-/, '')}`, String(value));
44
+ }
45
+ }
46
+
47
+ if (!onCustomResolve) {
48
+ el.onload = () => {
49
+ log({ tag, type: 'PROCESS_COMPLETED', config });
50
+ onComplete?.();
51
+ resolve();
52
+ };
53
+ }
54
+ el.onerror = (e) => {
55
+ error({ tag, type: 'PROCESS_FAILED', config });
56
+ onError?.(e);
57
+ resolve(e);
58
+ };
59
+ if (dom) {
60
+ (footer ? document.body : document.head).appendChild(el);
61
+ }
62
+ onCustomResolve?.({ el, config, resolve });
63
+ } catch (e) {
64
+ error({ tag, type: 'PROCESS_FAILED', config, error: e });
65
+ onError?.(e);
66
+ resolve(e);
67
+ }
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Render an inline script.
73
+ */
74
+ const InlineScript = {
75
+ type: 'inline-script',
76
+ handler: (config, callbacks) => {
77
+ let storedResolve = null;
78
+ let alreadyFired = false;
79
+ const eventName = `QSL:inline-script:completed:${config.id}`;
80
+ const resolveProcess = () => {
81
+ log({ tag: 'inline-script', type: 'INLINE_SCRIPT_SUCCESS', config });
82
+ log({ tag: 'inline-script', type: 'PROCESS_COMPLETED', config });
83
+ config?.onComplete?.();
84
+ storedResolve?.();
85
+ };
86
+ const normalizedConfig = {
87
+ ...config,
88
+ code: config.code?.replace(/<script.*?>|<\/script>/gi, '')
89
+ };
90
+ return render({
91
+ ...normalizedConfig,
92
+ tag: 'script',
93
+ dom: true,
94
+ onElement: (el, config) => {
95
+ const { code, module, id, flowId } = config;
96
+ if (code) el.textContent = code;
97
+ if (module) {
98
+ el.type = 'module';
99
+ const originalCode = el.textContent;
100
+ const fid = JSON.stringify(String(flowId));
101
+ const pid = JSON.stringify(String(id));
102
+ const evt = JSON.stringify(String(eventName));
103
+ const wrappedCode = `(function(){window.__QSL__.currentProcessPerFlow.set(${fid},${pid});try{${originalCode}}finally{window.__QSL__.currentProcessPerFlow.delete(${fid});window.dispatchEvent(new Event(${evt}));}})();`;
104
+ el.textContent = wrappedCode;
105
+ const handler = () => {
106
+ window.removeEventListener(eventName, handler);
107
+ if (storedResolve) {
108
+ resolveProcess();
109
+ } else {
110
+ alreadyFired = true;
111
+ }
112
+ };
113
+ window.addEventListener(eventName, handler);
114
+ }
115
+ },
116
+ onCustomResolve: ({ el, config, resolve }) => {
117
+ const { module } = config;
118
+ storedResolve = resolve;
119
+ if (module && alreadyFired) {
120
+ resolveProcess();
121
+ } else if (!module) {
122
+ queueMicrotask(() => {
123
+ if (storedResolve === resolve) resolveProcess();
124
+ });
125
+ }
126
+ }
127
+ }, callbacks);
128
+ }
129
+ };
130
+
131
+ /**
132
+ * Render a script element.
133
+ */
134
+ const Script = {
135
+ type: 'script',
136
+ handler: (config, callbacks) => {
137
+ return render({
138
+ ...config,
139
+ tag: 'script',
140
+ dom: true,
141
+ onElement: (el, { src, module, async, defer, crossOrigin, integrity, bypassCache }) => {
142
+ if (module) el.type = 'module';
143
+ if (async) el.async = async;
144
+ if (defer) el.defer = defer;
145
+ if (crossOrigin) el.crossOrigin = crossOrigin;
146
+ if (integrity) el.integrity = integrity;
147
+ if (src) el.src = src + bypassSuffix(src, bypassCache);
148
+ }
149
+ }, callbacks);
150
+ }
151
+ };
152
+
153
+ /**
154
+ * Render an inline style.
155
+ */
156
+ const InlineStyle = {
157
+ type: 'style',
158
+ handler: (config, callbacks) => {
159
+ const normalizedConfig = {
160
+ ...config,
161
+ code: config.code?.replace(/<style.*?>|<\/style>/gi, '')
162
+ };
163
+ return render({
164
+ ...normalizedConfig,
165
+ tag: 'style',
166
+ dom: true,
167
+ onElement: (el, { code }) => {
168
+ if (code) el.textContent = code;
169
+ },
170
+ onCustomResolve: ({ el, config, resolve }) => {
171
+ const { tag, onComplete } = config;
172
+ log({ tag, type: 'INLINE_STYLE_SUCCESS', config });
173
+ log({ tag, type: 'PROCESS_COMPLETED', config });
174
+ onComplete?.();
175
+ resolve();
176
+ }
177
+ }, callbacks);
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Render a stylesheet.
183
+ */
184
+ const Stylesheet = {
185
+ type: 'stylesheet',
186
+ handler: (config, callbacks) => {
187
+ return render({
188
+ ...config,
189
+ tag: 'link',
190
+ dom: true,
191
+ onElement: (el, { href, crossOrigin, bypassCache }) => {
192
+ el.rel = 'stylesheet';
193
+ if (crossOrigin) el.crossOrigin = crossOrigin;
194
+ el.href = href + bypassSuffix(href, bypassCache);
195
+ }
196
+ }, callbacks);
197
+ }
198
+ };
199
+
200
+ /**
201
+ * Render a pixel.
202
+ */
203
+ const Pixel = {
204
+ type: 'pixel',
205
+ handler: (config, callbacks) => {
206
+ return render({
207
+ ...config,
208
+ tag: 'img',
209
+ dom: true,
210
+ onElement: (el, { style = { display: 'none' }, dom, src, bypassCache }) => {
211
+ if ( ! dom ) el = new window.Image();
212
+ el.src = src + bypassSuffix(src, bypassCache);
213
+ el.width = 1;
214
+ el.height = 1;
215
+ if ( style && typeof style === 'object' ) {
216
+ for (const [key, value] of Object.entries(style)) {
217
+ el.style.setProperty(key, value);
218
+ }
219
+ }
220
+ },
221
+ onCustomResolve: ({ el, config, resolve }) => {
222
+ const { tag, dom, onComplete } = config;
223
+ const resolver = () => {
224
+ log({ tag, type: 'IMAGE_LOADED', config });
225
+ log({ tag, type: 'PROCESS_COMPLETED', config });
226
+ onComplete?.();
227
+ resolve();
228
+ };
229
+ ! dom ? resolver() : el.onload = () => resolver();
230
+ }
231
+ }, callbacks);
232
+ }
233
+ };
234
+
235
+ /**
236
+ * Render a custom element.
237
+ */
238
+ const Shadow = {
239
+ type: 'shadow',
240
+ handler: (config, callbacks) => {
241
+ return render({
242
+ ...config,
243
+ onBeforeStart: async ({ tag }) => await window.customElements.whenDefined(tag),
244
+ onElement: (el, { shadowData }) => {
245
+ el.data = shadowData || {};
246
+ if (shadowData?.hidden) el.setAttribute('hidden', '');
247
+ },
248
+ onCustomResolve: ({ el, config, resolve }) => {
249
+ const { tag, shadowData, onComplete, onError } = config;
250
+ const resolver = () => {
251
+ const selector = shadowData?.container;
252
+ let container = null;
253
+ if (selector === 'body') {
254
+ container = document.body;
255
+ } else if (typeof selector === 'string' && selector) {
256
+ try {
257
+ container = document.querySelector(selector);
258
+ } catch (e) {
259
+ container = null;
260
+ }
261
+ }
262
+ if (!container) {
263
+ const err = new Error(`Container not found: ${selector}`);
264
+ error({ tag, type: 'SHADOW_FAILED', config, error: err });
265
+ onError?.(err);
266
+ resolve(err);
267
+ return;
268
+ }
269
+ shadowData?.position === 'top' ? container.insertBefore(el, container.firstChild) : container.appendChild(el);
270
+ log({ tag, type: 'SHADOW_SUCCESS', config });
271
+ log({ tag, type: 'PROCESS_COMPLETED', config });
272
+ onComplete?.();
273
+ resolve();
274
+ };
275
+ if (document.readyState === 'interactive' || document.readyState === 'complete') {
276
+ resolver();
277
+ } else {
278
+ document.addEventListener('DOMContentLoaded', resolver, { once: true });
279
+ }
280
+ }
281
+ }, callbacks);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Render an HTML element.
287
+ */
288
+ const HTML = {
289
+ type: 'html',
290
+ handler: (config, callbacks) => {
291
+ return render({
292
+ ...config,
293
+ dom: true,
294
+ onElement: (el, { html = '', id = '', className = '', style = {} }) => {
295
+ if (html) el.innerHTML = html;
296
+ if (id) el.id = id;
297
+ if (className) el.className = Array.isArray(className) ? className.join(' ') : className;
298
+ if ( style && typeof style === 'object' ) {
299
+ for (const [key, value] of Object.entries(style)) {
300
+ el.style.setProperty(key, value);
301
+ }
302
+ }
303
+ },
304
+ onCustomResolve: ({ el, config, resolve }) => {
305
+ const { tag, onComplete } = config;
306
+ log({ tag, type: 'HTML_SUCCESS', config });
307
+ log({ tag, type: 'PROCESS_COMPLETED', config });
308
+ onComplete?.();
309
+ resolve();
310
+ }
311
+ }, callbacks);
312
+ }
313
+ };
314
+
315
+ /**
316
+ * Default types for QSL.
317
+ */
318
+ export { Script, Stylesheet, InlineScript, InlineStyle, Pixel, Shadow, HTML };