@excom/kit-utils 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.
package/dom.ts ADDED
@@ -0,0 +1,511 @@
1
+ import { isNumber, isPojo } from "./common";
2
+ import { LoopGuard } from "./loop-guard";
3
+
4
+ export const createElement = (
5
+ tagName,
6
+ props = {},
7
+ children: HTMLElement[] = []
8
+ ) => {
9
+ const element = document.createElement(tagName);
10
+ Object.entries(props).forEach(([key, value]) => {
11
+ if (key === "attributes") {
12
+ Object.entries(value || {}).forEach(([attrName, attrValue]) => {
13
+ element.setAttribute(attrName, attrValue);
14
+ });
15
+ } else {
16
+ element[key] = value;
17
+ }
18
+ });
19
+ children.forEach((child) => {
20
+ if (typeof child === "string") {
21
+ element.appendChild(document.createTextNode(child));
22
+ } else {
23
+ element.appendChild(child);
24
+ }
25
+ });
26
+ return element;
27
+ };
28
+
29
+ export const getChildren = (element: Element) => {
30
+ const templateChild =
31
+ element.children?.[0] instanceof HTMLTemplateElement
32
+ ? element.children?.[0]
33
+ : null;
34
+ const otherChildren = templateChild
35
+ ? [...element.children].slice(1)
36
+ : [...element.children];
37
+ return {
38
+ templateChild,
39
+ otherChildren,
40
+ };
41
+ };
42
+
43
+ /* TODO optimize this function. Very heavy. */
44
+ /**
45
+ * Replace `element`'s non-`<template>` children with `newChildren`.
46
+ * Returns whether the child list changed: `false` also when the loop
47
+ * guard dropped the write (child insertions are one causal hop, keyed
48
+ * `"content"` on the parent, the same key a childList observer maps to).
49
+ */
50
+ export const replaceNonTemplateChildren = (
51
+ element: Element,
52
+ newChildren: Node[] = []
53
+ ): boolean =>
54
+ LoopGuard.write(element, "content", () =>
55
+ replaceChildrenUnguarded(element, newChildren)
56
+ );
57
+
58
+ const replaceChildrenUnguarded = (
59
+ element: Element,
60
+ newChildren: Node[] = []
61
+ ) => {
62
+ let didChangeChildren = false;
63
+ if (newChildren.length > 0) {
64
+ newChildren.forEach((newChild, index) => {
65
+ // children mutate each pass, so re-read otherChildren
66
+ const { otherChildren } = getChildren(element);
67
+ const oldChild = otherChildren[index];
68
+ if (oldChild) {
69
+ if (oldChild !== newChild) {
70
+ if (
71
+ newChild.parentElement === element &&
72
+ newChild instanceof HTMLElement
73
+ ) {
74
+ /* Already a sibling: swap via a placeholder so the live
75
+ * child list stays intact. Neutron can optimize the move. */
76
+ newChild.replaceWith(document.createElement("div"));
77
+ oldChild.replaceWith(newChild);
78
+ didChangeChildren = true;
79
+ } else {
80
+ // inbound node, not already here
81
+ oldChild.replaceWith(newChild);
82
+ didChangeChildren = true;
83
+ }
84
+ } else {
85
+ // same node at this index
86
+ }
87
+ } else {
88
+ // past the old list: append
89
+ element.appendChild(newChild);
90
+ didChangeChildren = true;
91
+ }
92
+ });
93
+ const { otherChildren } = getChildren(element);
94
+ if (newChildren.length < otherChildren.length) {
95
+ otherChildren.slice(newChildren.length).forEach((oldChild) => {
96
+ // leftover old nodes
97
+ oldChild.remove();
98
+ didChangeChildren = true;
99
+ });
100
+ }
101
+ } else {
102
+ // empty new list: strip everything but the <template>
103
+ const { otherChildren } = getChildren(element);
104
+ otherChildren.forEach((oldChild) => {
105
+ oldChild.remove();
106
+ didChangeChildren = true;
107
+ });
108
+ }
109
+ return didChangeChildren;
110
+ };
111
+
112
+ const wrapContent = (content: Node) => {
113
+ const wrapper = document.createElement("div");
114
+ wrapper.appendChild(content);
115
+ return wrapper;
116
+ };
117
+
118
+ export const buildContent = (
119
+ _content: DocumentFragment,
120
+ options?: { skipCloning?: boolean }
121
+ ) => {
122
+ const content = (
123
+ options?.skipCloning
124
+ ? _content
125
+ : (document.importNode(_content, true) as unknown as Element)
126
+ ) as Element;
127
+ const numberOfChildren = content.children?.length;
128
+ return numberOfChildren === 1 ? content.children?.[0] : wrapContent(content);
129
+ };
130
+
131
+ const getRootNode = (scope?: Element | Document | null): Document | null => {
132
+ return (scope as any)?.getRootNode?.() ?? document;
133
+ };
134
+
135
+ const getRoot = (
136
+ root?: string,
137
+ scope?: Element | null
138
+ ): Element | Document | null => {
139
+ if (!root) return getRootNode(scope) || null;
140
+ if (!scope) return getRootNode(scope)?.querySelector?.(root) || null;
141
+ return scope.closest?.(root) || null;
142
+ };
143
+
144
+ function recognizeRootRef(ref: string) {
145
+ if (ref === "window") return window;
146
+ if (ref === "document") return document;
147
+ if (ref === "html") return document.documentElement;
148
+ if (ref === "body") return document.body;
149
+ if (ref === "head") return document.head;
150
+ return null;
151
+ }
152
+
153
+ type SelectRootRefResult<EnableRootRefs extends boolean> =
154
+ EnableRootRefs extends true ? Window | Document | Element : never;
155
+
156
+ type SelectFnResult<Fn extends "querySelector" | "querySelectorAll"> =
157
+ Fn extends "querySelector" ? HTMLElement | null : HTMLElement[] | null;
158
+
159
+ type SelectResult<
160
+ Fn extends "querySelector" | "querySelectorAll",
161
+ EnableRootRefs extends boolean,
162
+ > = SelectFnResult<Fn> | SelectRootRefResult<EnableRootRefs>;
163
+
164
+ export const _select = <
165
+ Fn extends "querySelector" | "querySelectorAll",
166
+ EnableRootRefs extends boolean = false,
167
+ >(
168
+ _selector: string,
169
+ {
170
+ root,
171
+ scope,
172
+ fn,
173
+ enableRootRefs,
174
+ }: {
175
+ root?: string;
176
+ scope?: Element;
177
+ fn: Fn;
178
+ enableRootRefs?: EnableRootRefs;
179
+ }
180
+ ): SelectResult<Fn, EnableRootRefs> => {
181
+ if (enableRootRefs) {
182
+ const _root = recognizeRootRef(_selector.trim());
183
+ if (_root) return _root as SelectRootRefResult<EnableRootRefs>;
184
+ }
185
+ const _root = getRoot(root, scope);
186
+ if (!_root) return null;
187
+ let selector = _selector;
188
+ let tempAttr = "";
189
+ /* Only a selector that mentions `:scope` needs the scope element tagged */
190
+ const tagScope = !!scope && _root !== scope && _selector.includes(":scope");
191
+ if (tagScope) {
192
+ tempAttr = `n-util-select-id-${Math.random().toString(36).substring(2, 11)}`;
193
+ scope!.setAttribute(tempAttr, "");
194
+ selector = selector.replace(/:scope/g, `[${tempAttr}]`);
195
+ }
196
+ const result = (_root as any)[fn](selector);
197
+ if (tagScope) {
198
+ scope!.removeAttribute(tempAttr);
199
+ }
200
+ return (
201
+ fn === "querySelector"
202
+ ? (result as HTMLElement | null)
203
+ : Array.from(result as NodeListOf<HTMLElement>)
204
+ ) as SelectFnResult<Fn>;
205
+ };
206
+
207
+ export const selectOne = <EnableRootRefs extends boolean = false>(
208
+ selector: string,
209
+ opts: {
210
+ root?: string;
211
+ scope?: Element;
212
+ enableRootRefs?: EnableRootRefs;
213
+ } = {}
214
+ ): SelectResult<"querySelector", EnableRootRefs> => {
215
+ return _select(selector, {
216
+ root: opts.root,
217
+ scope: opts.scope,
218
+ fn: "querySelector",
219
+ enableRootRefs: opts.enableRootRefs,
220
+ });
221
+ };
222
+
223
+ export const selectAll = <EnableRootRefs extends boolean = false>(
224
+ selector: string,
225
+ opts: {
226
+ root?: string;
227
+ scope?: Element;
228
+ enableRootRefs?: EnableRootRefs;
229
+ } = {}
230
+ ): SelectResult<"querySelectorAll", EnableRootRefs> => {
231
+ return _select(selector, {
232
+ root: opts.root,
233
+ scope: opts.scope,
234
+ fn: "querySelectorAll",
235
+ enableRootRefs: opts.enableRootRefs,
236
+ });
237
+ };
238
+
239
+ export const attributesToEntries = (
240
+ attributes: NamedNodeMap,
241
+ attrNamespace?: string,
242
+ attrFormatter?: (attr: Attr) => [string, string]
243
+ ): [string, string][] =>
244
+ Object.values(attributes)
245
+ .filter(
246
+ (a) => a?.name && (!attrNamespace || a.name.startsWith(attrNamespace))
247
+ )
248
+ .map((a) =>
249
+ attrFormatter ? attrFormatter(a) : ([a.name, a.value] as [string, string])
250
+ );
251
+
252
+ export const attributesToObject = (
253
+ attributes: NamedNodeMap,
254
+ attrNamespace?: string,
255
+ attrFormatter?: (attr: Attr) => [string, string]
256
+ ): Record<string, string> => {
257
+ return Object.fromEntries(
258
+ attributesToEntries(attributes, attrNamespace, attrFormatter)
259
+ );
260
+ };
261
+
262
+ /**
263
+ * Prop-type marker for space-separated token attributes
264
+ * (`props: { eventNames: TokenList }` ↔ `event-names="a b"`).
265
+ * The runtime value is a plain `string[]`; this class is never instantiated.
266
+ * It exists so a prop config can name the type the way it names `String` or
267
+ * `Number`. Shipped with the Neutron factory (`import { TokenList } from
268
+ * "@excom/neutron"`).
269
+ */
270
+ export class TokenList extends Array<string> {}
271
+
272
+ type AttrType =
273
+ | typeof Boolean
274
+ | typeof Number
275
+ | typeof String
276
+ | typeof TokenList
277
+ | "boolean"
278
+ | "number"
279
+ | "string"
280
+ | "tokens";
281
+ type AttrValue = string | null;
282
+ export const Converter = {
283
+ boolean: {
284
+ attr: {
285
+ convert: (attrValue: AttrValue) => (attrValue === null ? false : true),
286
+ isTruthy: (attrValue: AttrValue) => !isNullish(attrValue),
287
+ defaultValue: null,
288
+ },
289
+ prop: {
290
+ convert: (propValue: unknown) =>
291
+ propValue || typeof propValue === "string" ? "" : null,
292
+ isTruthy: (propValue: unknown) => !!propValue,
293
+ defaultValue: false,
294
+ },
295
+ },
296
+ number: {
297
+ attr: {
298
+ convert: (attrValue: AttrValue) => {
299
+ if (["", null].includes(attrValue)) return null;
300
+ const num = Number(attrValue);
301
+ return isNumber(num) ? num : null;
302
+ },
303
+ isTruthy: (attrValue: AttrValue) =>
304
+ !isNullish(attrValue) && attrValue !== "",
305
+ defaultValue: null,
306
+ },
307
+ prop: {
308
+ convert: (propValue: unknown) =>
309
+ isNullish(propValue) || propValue === "" ? null : propValue + "",
310
+ isTruthy: (propValue: unknown) => isNumber(propValue),
311
+ defaultValue: null,
312
+ },
313
+ },
314
+ string: {
315
+ attr: {
316
+ convert: (attrValue: AttrValue) =>
317
+ isNullish(attrValue) ? null : attrValue,
318
+ isTruthy: (attrValue: AttrValue) => !isNullish(attrValue),
319
+ defaultValue: null,
320
+ },
321
+ prop: {
322
+ convert: (propValue: unknown) =>
323
+ isNullish(propValue) ? null : propValue + "",
324
+ isTruthy: (propValue: unknown) => !isNullish(propValue),
325
+ defaultValue: null,
326
+ },
327
+ },
328
+ tokens: {
329
+ attr: {
330
+ convert: (attrValue: AttrValue) => {
331
+ if (isNullish(attrValue)) return null;
332
+ return attrValue
333
+ .split(" ")
334
+ .map((t) => t.trim())
335
+ .filter((t) => t);
336
+ },
337
+ isTruthy: (attrValue: AttrValue) => !isNullish(attrValue),
338
+ defaultValue: null,
339
+ },
340
+ prop: {
341
+ convert: (propValue: unknown) => {
342
+ if (Array.isArray(propValue)) {
343
+ return propValue.filter((v) => !isNullish(v) && v !== "").join(" ");
344
+ } else {
345
+ return null;
346
+ }
347
+ },
348
+ isTruthy: (propValue: unknown) => Array.isArray(propValue),
349
+ defaultValue: null,
350
+ },
351
+ },
352
+ nonPrimitive: {
353
+ attr: {
354
+ convert: (_: AttrValue) => {
355
+ throw new Error("Cannot convert non-primitive to attribute");
356
+ },
357
+ isTruthy: (attrValue: AttrValue) => !isNullish(attrValue),
358
+ defaultValue: null,
359
+ },
360
+ prop: {
361
+ convert: (propValue: unknown) => {
362
+ try {
363
+ return Array.isArray(propValue)
364
+ ? propValue.length + ""
365
+ : isPojo(propValue)
366
+ ? Object.keys(propValue as Record<string, unknown>).length + ""
367
+ : !!propValue
368
+ ? ""
369
+ : null;
370
+ } catch (error) {
371
+ return !!propValue ? "" : null;
372
+ }
373
+ },
374
+ isTruthy: (propValue: unknown) => !isNullish(propValue),
375
+ defaultValue: null,
376
+ },
377
+ },
378
+ getAttrName: (propName: string) => camelToDash(propName),
379
+ getPropName: (attrName: string) => dashToCamel(attrName),
380
+ type: <T>(
381
+ type: T,
382
+ options?: { convertNonPrimitives?: boolean }
383
+ ): T extends AttrType ? (typeof Converter)["string"] : void => {
384
+ switch (type) {
385
+ case String:
386
+ return Converter.string;
387
+ case Boolean:
388
+ return Converter.boolean;
389
+ case Number:
390
+ return Converter.number;
391
+ case TokenList:
392
+ return Converter.tokens;
393
+ default:
394
+ if (
395
+ ["string", "number", "boolean", "tokens"].includes(type as string)
396
+ ) {
397
+ return Converter[type];
398
+ } else if (options?.convertNonPrimitives) {
399
+ if (isNullish(type)) {
400
+ return undefined;
401
+ } else {
402
+ return Converter.nonPrimitive;
403
+ }
404
+ } else {
405
+ return undefined;
406
+ }
407
+ }
408
+ },
409
+ };
410
+
411
+ export const setAttr = (
412
+ elementOrNamedNodeMap: HTMLElement | NamedNodeMap,
413
+ name: string,
414
+ value: unknown
415
+ ) => {
416
+ const shouldRemove = isNullish(value);
417
+ if (elementOrNamedNodeMap instanceof HTMLElement) {
418
+ const element = elementOrNamedNodeMap;
419
+ const oldVal = element.getAttribute(name);
420
+ // caller (setProp) often already compared; cheap to re-check
421
+ if (value !== oldVal) {
422
+ // one causal hop: a runaway chain (effect ↔ attribute) is cut here
423
+ LoopGuard.write(element, name, () => {
424
+ element[shouldRemove ? "removeAttribute" : "setAttribute"](
425
+ name,
426
+ value as string
427
+ );
428
+ });
429
+ }
430
+ } else {
431
+ const attributes = elementOrNamedNodeMap;
432
+ if (shouldRemove) {
433
+ attributes.removeNamedItem(name);
434
+ } else {
435
+ let attr = attributes.getNamedItem(name);
436
+ if (value !== attr?.value) {
437
+ if (!attr) {
438
+ attr = document.createAttribute(name);
439
+ attributes.setNamedItem(attr);
440
+ }
441
+ attr.value = value as string;
442
+ }
443
+ }
444
+ }
445
+ };
446
+
447
+ export const getAttr = (
448
+ elementOrNamedNodeMap: HTMLElement | NamedNodeMap,
449
+ name: string
450
+ ) => {
451
+ if (elementOrNamedNodeMap instanceof HTMLElement) {
452
+ return elementOrNamedNodeMap.getAttribute(name);
453
+ } else {
454
+ const attr = elementOrNamedNodeMap.getNamedItem(name);
455
+ return attr?.value ?? null;
456
+ }
457
+ };
458
+
459
+ export const isNullish = <T>(value: T): value is T & (null | undefined) =>
460
+ [null, undefined].includes(value as any);
461
+
462
+ export const isPrimitive = (value: unknown): boolean =>
463
+ ["string", "number", "boolean", "tokens"].includes(value as any);
464
+
465
+ export const isPrimitiveConstructor = (value: AttrType | any): boolean =>
466
+ [String, Number, Boolean, TokenList].includes(value);
467
+
468
+ export const dashToCamel = (str: string): string =>
469
+ str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
470
+
471
+ export const camelToDash = (str: string): string =>
472
+ str.replace(/([A-Z])/g, (g) => `-${g[0].toLowerCase()}`);
473
+
474
+ export const objToAttrs = (
475
+ obj: Record<string, unknown>,
476
+ options: {
477
+ preserveKey?: boolean;
478
+ prefix?: string;
479
+ convertNonPrimitives?: boolean;
480
+ } = {}
481
+ ): Record<string, string | null> => {
482
+ const prefix = typeof options.prefix === "string" ? options.prefix : "";
483
+ return Object.fromEntries(
484
+ Object.entries(obj).map(([key, val]) => {
485
+ const valType =
486
+ Array.isArray(val) &&
487
+ val.every((v) => typeof v === "string" || isNullish(v))
488
+ ? TokenList
489
+ : typeof val;
490
+ const converter = Converter.type(valType, {
491
+ convertNonPrimitives: options?.convertNonPrimitives,
492
+ });
493
+ return [
494
+ `${prefix}${options?.preserveKey ? key.toLowerCase() : camelToDash(key)}`,
495
+ converter?.prop.convert(val) ?? null,
496
+ ];
497
+ })
498
+ );
499
+ };
500
+
501
+ export const PropSerializer = {
502
+ dfault: {
503
+ serialize: (v: unknown) => v,
504
+ deserialize: (v: unknown) => v,
505
+ },
506
+ weak: {
507
+ serialize: (v: unknown) => (v ? new WeakRef(v) : v),
508
+ deserialize: (v: unknown | WeakRef<WeakKey>) =>
509
+ v instanceof WeakRef ? v.deref() : v,
510
+ },
511
+ };
package/fetching.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { execWhenReady, pathJoin } from "./common";
2
+ import { buildContent, selectOne } from "./dom";
3
+
4
+ const TEMPLATES: {
5
+ [templateRef: string]: DocumentFragment | Promise<DocumentFragment>;
6
+ } = {};
7
+
8
+ export const fetchTemplate = async (
9
+ templateRef: string,
10
+ options: { reqInit?: RequestInit } = {}
11
+ ) => {
12
+ const html = await fetch(templateRef, options?.reqInit || {}).then((res) =>
13
+ res.text()
14
+ );
15
+ const template = document.createElement("template");
16
+ template.innerHTML = html;
17
+ const content = template.content;
18
+ return content;
19
+ };
20
+
21
+ /**
22
+ * Resolve a `<template>` from a URL or DOM selector.
23
+ * Cached URL hits return a fragment synchronously; the first fetch
24
+ * returns a Promise. Callers that must always await can use
25
+ * `wrapInPromise`.
26
+ */
27
+ export const resolveTemplateContent = (
28
+ templateRef: string,
29
+ options?: {
30
+ scope?: Element;
31
+ skipCloning?: boolean;
32
+ bypassCache?: boolean;
33
+ reqInit?: RequestInit;
34
+ }
35
+ ): DocumentFragment | Promise<DocumentFragment> | null => {
36
+ let templateContent: DocumentFragment | Promise<DocumentFragment> | null;
37
+ if (/^\/|^\.\/|^\.\.\/|^http/g.test(templateRef)) {
38
+ // URL: cache by templateRef
39
+ if (
40
+ !options?.bypassCache &&
41
+ TEMPLATES[templateRef] instanceof DocumentFragment
42
+ ) {
43
+ templateContent = TEMPLATES[templateRef];
44
+ } else if (options?.bypassCache || !TEMPLATES[templateRef]) {
45
+ /* bypassCache also refreshes the cache so later reads of this
46
+ * templateRef see the new content. */
47
+ const pending = fetchTemplate(templateRef, {
48
+ reqInit: options?.reqInit,
49
+ }).then((content) => {
50
+ // a purge while in flight must not be undone by the settle
51
+ if (TEMPLATES[templateRef] === pending)
52
+ TEMPLATES[templateRef] = content;
53
+ return content;
54
+ });
55
+ TEMPLATES[templateRef] = pending;
56
+ }
57
+ templateContent = TEMPLATES[templateRef];
58
+ } else {
59
+ // selector: live <template> in the DOM
60
+ const scope = (options?.scope || document) as Element;
61
+ if (!scope.querySelector) {
62
+ throw new Error(
63
+ "resolveTemplateContent requires a scope with querySelector"
64
+ );
65
+ }
66
+ templateContent =
67
+ (
68
+ selectOne(templateRef, {
69
+ scope,
70
+ }) as HTMLTemplateElement
71
+ )?.content ?? null;
72
+ }
73
+ if (!templateContent) {
74
+ throw new Error(`Template not found: ${templateRef}`);
75
+ }
76
+ return execWhenReady(templateContent, (t) =>
77
+ buildContent(t, {
78
+ skipCloning: options?.skipCloning,
79
+ })
80
+ );
81
+ };
82
+
83
+ export const resolveModuleReference = async (ref: string) =>
84
+ await import(/* @vite-ignore */ pathJoin([window.location.origin, ref]));
85
+
86
+ const PLAIN_TEXTS: { [url: string]: Promise<string> } = {};
87
+
88
+ /**
89
+ * Drop cached fetch results, the template fragments `resolveTemplateContent`
90
+ * keeps per URL and the text `fetchPlainText` keeps per URL. Both caches are
91
+ * module-level and shared by every element on the page (`include-content`,
92
+ * `spa-route`, `quark-sheet`), so a purge affects all of them: pass a `url`
93
+ * to forget one entry, omit it to forget everything. In-flight requests are
94
+ * not cancelled; their result simply is not kept. Returns the number of
95
+ * entries removed.
96
+ */
97
+ export const clearFetchCaches = (url?: string): number => {
98
+ const stores: Array<Record<string, unknown>> = [TEMPLATES, PLAIN_TEXTS];
99
+ let removed = 0;
100
+ for (const store of stores) {
101
+ const keys =
102
+ url === undefined ? Object.keys(store) : url in store ? [url] : [];
103
+ for (const key of keys) {
104
+ delete store[key];
105
+ removed++;
106
+ }
107
+ }
108
+ return removed;
109
+ };
110
+ export const fetchPlainText = async (
111
+ url: string,
112
+ options?: { reqInit?: RequestInit }
113
+ ) => {
114
+ if (!PLAIN_TEXTS[url]) {
115
+ PLAIN_TEXTS[url] = fetch(url, options?.reqInit || {}).then((res) =>
116
+ res.text()
117
+ );
118
+ }
119
+ return PLAIN_TEXTS[url];
120
+ };