@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/.rush/temp/chunked-rush-logs/kit-utils.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +4 -0
- package/batch-manager.ts +109 -0
- package/common.ts +234 -0
- package/config/rig.json +6 -0
- package/dom.ts +511 -0
- package/fetching.ts +120 -0
- package/form.ts +99 -0
- package/index.ts +9 -0
- package/load-dependency.ts +40 -0
- package/loop-guard.ts +187 -0
- package/package.json +39 -0
- package/property.ts +225 -0
- package/queue-manager.ts +214 -0
- package/rush-logs/kit-utils.apply-exports.cache.log +1 -0
- package/rush-logs/kit-utils.apply-exports.log +1 -0
- package/support/tests/batch-manager.test.ts +381 -0
- package/support/tests/common.test.ts +376 -0
- package/support/tests/dom.test.ts +852 -0
- package/support/tests/fetching.test.ts +230 -0
- package/support/tests/form.test.ts +297 -0
- package/support/tests/index.test.ts +20 -0
- package/support/tests/load-dependency.test.ts +98 -0
- package/support/tests/loop-guard.test.ts +225 -0
- package/support/tests/property.test.ts +355 -0
- package/support/tests/queue-manager.test.ts +471 -0
- package/support/tests/url.test.ts +111 -0
- package/tsconfig.json +5 -0
- package/url.ts +39 -0
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
|
+
};
|