@aegisjsproject/atlas 0.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/atlas.cjs +669 -0
- package/atlas.js +3 -0
- package/atlas.min.js +2 -0
- package/atlas.min.js.map +1 -0
- package/package.json +83 -0
- package/preload.js +241 -0
- package/router.js +338 -0
- package/routes.js +74 -0
package/router.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { registerModule, lookupRoute } from './routes.js';
|
|
2
|
+
import { observePreloadsOn } from './preload.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This is necessary since an HTML response from a same-origin
|
|
6
|
+
* request should result in the same document state as if
|
|
7
|
+
* it were initial load. CSP/Trusted Types requires `TrustedHTML`
|
|
8
|
+
* for `Document.parseHTMLUnsage` (or `innerHTML`), and `setHTML()`
|
|
9
|
+
* would filter out any `<iframe>` or `onclick` or `<form action>`.
|
|
10
|
+
*/
|
|
11
|
+
const policy = 'trustedTypes' in globalThis
|
|
12
|
+
? trustedTypes.createPolicy('aegis-atlas#html', {
|
|
13
|
+
createHTML(input) {
|
|
14
|
+
return input;
|
|
15
|
+
}
|
|
16
|
+
}) : Object.freeze({
|
|
17
|
+
createHTML(input) {
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const DESC_SELECTOR = 'meta[name="description"], meta[itemprop="description"], meta[property="og:description"], meta[name="twitter:description"]';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef RouteContextObject
|
|
26
|
+
* @property {URLPatternResult} result
|
|
27
|
+
* @property {Record<string, string>} params
|
|
28
|
+
* @property {DisposableStack} stack
|
|
29
|
+
* @property {AbortController} controller
|
|
30
|
+
* @property {AbortSignal} signal
|
|
31
|
+
* @property {NavigationType} type
|
|
32
|
+
* @property {URL} url
|
|
33
|
+
* @property {any} state
|
|
34
|
+
* @property {any} info
|
|
35
|
+
* @property {number} timestamp
|
|
36
|
+
* @readonly
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @typedef {Response|DocumentFragment|Element|HTMLDocument|URL} HandlerResult */
|
|
40
|
+
/** @typedef {(request: Request, context: RouteContextObject) => Promise<HandlerResult>} RouteHandler */
|
|
41
|
+
|
|
42
|
+
/** @typedef {Readonly<Record<string, unknown>> & {default?: RouteHandler|HandlerResult, title?: string, description?: string, styles?: CSSStyleSheet|CSSStyleSheet[]}} Module */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @type HTMLElement
|
|
46
|
+
*/
|
|
47
|
+
let root = document.body;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
*
|
|
51
|
+
* @param {string|HTMLElement} newRoot
|
|
52
|
+
* @param {DocumentOrShadowRoot} base
|
|
53
|
+
*/
|
|
54
|
+
export function setRoot(newRoot, base = document) {
|
|
55
|
+
if (typeof newRoot === 'string') {
|
|
56
|
+
setRoot(base.getElementById(newRoot));
|
|
57
|
+
} else if (newRoot instanceof HTMLElement) {
|
|
58
|
+
root = newRoot;
|
|
59
|
+
} else {
|
|
60
|
+
throw new TypeError('New root must be an `Element` or `id` of an element.');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
*
|
|
66
|
+
* @param {HTMLFormElement|HTMLButtonElement|HTMLAnchorElement} source
|
|
67
|
+
* @returns {"GET"|"POST"}
|
|
68
|
+
*/
|
|
69
|
+
function getRequestMethod(source) {
|
|
70
|
+
if (! (source instanceof HTMLElement) || source instanceof HTMLAnchorElement) {
|
|
71
|
+
return 'GET';
|
|
72
|
+
} else if (source instanceof HTMLFormElement) {
|
|
73
|
+
return source.method.toUpperCase();
|
|
74
|
+
} else if (! (source instanceof HTMLButtonElement)) {
|
|
75
|
+
return 'GET';
|
|
76
|
+
} else if (source.hasAttribute('formmethod') && source.formMethod.length !== 0) {
|
|
77
|
+
return source.formMethod.toUpperCase();
|
|
78
|
+
} else if (source.form instanceof HTMLFormElement) {
|
|
79
|
+
return source.form.method.toUpperCase();
|
|
80
|
+
} else {
|
|
81
|
+
console.warn('Not sure this should be possible...');
|
|
82
|
+
return 'GET';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
*
|
|
88
|
+
* @param {NavigationEvent} event
|
|
89
|
+
*/
|
|
90
|
+
export async function handleNavigation(event) {
|
|
91
|
+
if (! (event instanceof NavigateEvent)) {
|
|
92
|
+
throw new TypeError('Not a navigation event.');
|
|
93
|
+
} else if (event.signal.aborted) {
|
|
94
|
+
throw event.signal.reason;
|
|
95
|
+
} else {
|
|
96
|
+
const method = getRequestMethod(event.sourceElement);
|
|
97
|
+
const request = new Request(event.destination.url, {
|
|
98
|
+
// `sourceElement` could be a form, a `<button type="submit">`, or an `<a>
|
|
99
|
+
method: method,
|
|
100
|
+
body: method === 'GET' ? undefined : event.formData,// ?? new FormData(event.sourceElement?.form ?? event.sourceElement),
|
|
101
|
+
signal: event.signal,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const { result, specifier, hasRegExpGroups } = lookupRoute(event.destination.url);
|
|
105
|
+
|
|
106
|
+
if (typeof specifier !== 'string' || result === null) {
|
|
107
|
+
const resp = await fetch(request);
|
|
108
|
+
await updateContent(resp);
|
|
109
|
+
} else {
|
|
110
|
+
const params = hasRegExpGroups ? {
|
|
111
|
+
...result.protocol.groups, ...result.username.groups, ...result.password.groups, ...result.hostname.groups,
|
|
112
|
+
...result.port.groups, ...result.pathname.groups, ...result.search.groups, ...result.hash.groups,
|
|
113
|
+
}: {};
|
|
114
|
+
|
|
115
|
+
delete params['0'];
|
|
116
|
+
const module = await import(specifier);
|
|
117
|
+
const stack = new DisposableStack();
|
|
118
|
+
const controller = stack.adopt(
|
|
119
|
+
new AbortController(),
|
|
120
|
+
controller => controller.abort(new DOMException('Stack was disposed.', 'AbortError')),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const timestamp = performance.now();
|
|
124
|
+
const signal = AbortSignal.any([controller.signal, request.signal]);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @type {RouteContextObject}
|
|
128
|
+
*/
|
|
129
|
+
const context = Object.freeze({
|
|
130
|
+
timestamp,
|
|
131
|
+
stack,
|
|
132
|
+
controller,
|
|
133
|
+
type: event.navigationType,
|
|
134
|
+
state: event.destination.getState(),
|
|
135
|
+
info: event.info,
|
|
136
|
+
url: new URL(event.destination.url),
|
|
137
|
+
signal,
|
|
138
|
+
result,
|
|
139
|
+
params,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
return await handleRequestModule(request, context, module);
|
|
144
|
+
} catch(err) {
|
|
145
|
+
reportError(err);
|
|
146
|
+
} finally {
|
|
147
|
+
stack.dispose();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
*
|
|
155
|
+
* @param {unknown} routes
|
|
156
|
+
* @param {object} config
|
|
157
|
+
* @param {HTMLElement|string} [config.root]
|
|
158
|
+
* @param {boolean} [config.preload=false]
|
|
159
|
+
* @param {AbortSignal} [config.signal]
|
|
160
|
+
*/
|
|
161
|
+
export function init(routes, {
|
|
162
|
+
root,
|
|
163
|
+
preload = false,
|
|
164
|
+
signal,
|
|
165
|
+
} = {}) {
|
|
166
|
+
if (typeof routes === 'string') {
|
|
167
|
+
init(JSON.parse(document.scripts.namedItem(routes).textContent), { root, preload, signal });
|
|
168
|
+
} else if (typeof routes === 'number') {
|
|
169
|
+
init(JSON.parse(document.scripts.item(routes).textContent), { root, preload, signal });
|
|
170
|
+
} else if (routes instanceof HTMLScriptElement) {
|
|
171
|
+
init(JSON.parse(routes.textContent), { root, preload, signal });
|
|
172
|
+
} else if (typeof routes === 'object') {
|
|
173
|
+
Object.entries(routes).forEach(([key, val]) => registerModule(key, val));
|
|
174
|
+
|
|
175
|
+
if (typeof root === 'string' || root instanceof HTMLElement) {
|
|
176
|
+
setRoot(root);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
navigation.addEventListener('navigate', event => {
|
|
180
|
+
if (event.canIntercept && event.destination.url.startsWith(location.origin) && ! event.sourceElement?.classList?.contains?.('no-router')) {
|
|
181
|
+
event.intercept({ handler: () => handleNavigation(event) });
|
|
182
|
+
}
|
|
183
|
+
}, { signal });
|
|
184
|
+
|
|
185
|
+
if (preload) {
|
|
186
|
+
observePreloadsOn(document.body);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
throw new TypeError(`Routes must be an object, \`<script>\`, or name/index of \`document.scripts\`. Got a ${typeof routes}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
*
|
|
195
|
+
* @param {object} options
|
|
196
|
+
* @param {AbortSignal} [options.signal]
|
|
197
|
+
* @returns {Promise<NavigationHistoryEntry>}
|
|
198
|
+
*/
|
|
199
|
+
export async function whenLoaded({ signal } = {}) {
|
|
200
|
+
const { resolve, reject, promise } = Promise.withResolvers();
|
|
201
|
+
|
|
202
|
+
if (signal?.aborted) {
|
|
203
|
+
reject(signal.reason);
|
|
204
|
+
} else {
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
const opts = {
|
|
207
|
+
once: true,
|
|
208
|
+
signal: signal instanceof AbortSignal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
navigation.addEventListener('navigatesuccess', () => {
|
|
212
|
+
resolve(navigation.currentEntry);
|
|
213
|
+
controller.abort();
|
|
214
|
+
}, opts);
|
|
215
|
+
|
|
216
|
+
navigation.addEventListener('navigateerror', event => {
|
|
217
|
+
reject(event.error);
|
|
218
|
+
controller.abort();
|
|
219
|
+
}, opts);
|
|
220
|
+
|
|
221
|
+
if (signal instanceof AbortSignal) {
|
|
222
|
+
signal.addEventListener('abort', ({ target }) => {
|
|
223
|
+
reject(target.reason);
|
|
224
|
+
controller.abort(target.reason);
|
|
225
|
+
}, { once: true, signal: controller.signal });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return promise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
*
|
|
234
|
+
* @param {string|URL} newURL
|
|
235
|
+
* @param {NavigationOptions} options
|
|
236
|
+
* @returns {NavigationResult}
|
|
237
|
+
*/
|
|
238
|
+
export const navigate = (newURL, options) => navigation.navigate(newURL, options);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
*
|
|
242
|
+
* @param {NavigationOptions} options
|
|
243
|
+
* @returns {NavigationResult}
|
|
244
|
+
*/
|
|
245
|
+
export const back = (options) => navigation.back(options);
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
*
|
|
249
|
+
* @param {NavigationOptions} options
|
|
250
|
+
* @returns {NavigationResult}
|
|
251
|
+
*/
|
|
252
|
+
export const forward = (options) => navigation.forward(options);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
*
|
|
256
|
+
* @param {NavigationReloadOptions} options
|
|
257
|
+
* @returns {NavigationResult}
|
|
258
|
+
*/
|
|
259
|
+
export const reload = (options) => navigation.reload(options);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
*
|
|
263
|
+
* @param {Request} request
|
|
264
|
+
* @param {RouteContextObject} context
|
|
265
|
+
* @param {Module} module
|
|
266
|
+
*/
|
|
267
|
+
async function handleRequestModule(request, context, module) {
|
|
268
|
+
if (typeof module.default === 'undefined') {
|
|
269
|
+
throw new TypeError(`No default export in module for <${request.url}>.`);
|
|
270
|
+
} else if (typeof module.default === 'function') {
|
|
271
|
+
const result = await module.default(request, context);
|
|
272
|
+
await updateContent(result);
|
|
273
|
+
updateMeta(module);
|
|
274
|
+
} else {
|
|
275
|
+
await updateContent(module.default);
|
|
276
|
+
updateMeta(module);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function updateMeta({ title, description, styles }) {
|
|
281
|
+
if (typeof title === 'string') {
|
|
282
|
+
document.title = title;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (typeof description === 'string') {
|
|
286
|
+
setDescription(description);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (styles instanceof CSSStyleSheet) {
|
|
290
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles];
|
|
291
|
+
} else if (Array.isArray(styles) && styles.length !== 0) {
|
|
292
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...styles];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
*
|
|
298
|
+
* @param {HandlerResult} content
|
|
299
|
+
*/
|
|
300
|
+
async function updateContent(content) {
|
|
301
|
+
if (content instanceof URL) {
|
|
302
|
+
navigate(content);
|
|
303
|
+
} else if (content instanceof Response) {
|
|
304
|
+
if (! content.ok) {
|
|
305
|
+
throw new DOMException(`${content.url} [${content.status}]`, 'NetworkError');
|
|
306
|
+
} else if (! content.headers.get('Content-Type')?.startsWith?.('text/html')) {
|
|
307
|
+
throw new TypeError(`Unsupported Content-Type for <${content.url}> - "${content.headers.get('Content-Type') ?? 'Unset'}".`);
|
|
308
|
+
} else {
|
|
309
|
+
const html = await content.text();
|
|
310
|
+
/** @type HTMLDocument */
|
|
311
|
+
const doc = Document.parseHTMLUnsafe(policy.createHTML(html)); // Unsafe, but necessary... Same-origin at least
|
|
312
|
+
await updateContent(doc);
|
|
313
|
+
}
|
|
314
|
+
} else if (content instanceof Element || content instanceof DocumentFragment) {
|
|
315
|
+
root.replaceChildren(content);
|
|
316
|
+
} else if (content instanceof HTMLDocument) {
|
|
317
|
+
document.title = content.title;
|
|
318
|
+
setDescription(content.head.querySelector(DESC_SELECTOR)?.content);
|
|
319
|
+
|
|
320
|
+
if (root instanceof HTMLBodyElement) {
|
|
321
|
+
root.replaceChildren(...content.body.childNodes);
|
|
322
|
+
} else if (root instanceof HTMLElement && typeof root.id === 'string') {
|
|
323
|
+
root.replaceChildren(...content.getElementById(root.id)?.childNodes ?? []);
|
|
324
|
+
} else {
|
|
325
|
+
throw new TypeError('Root must be `<body>` or an element with an `id`.');
|
|
326
|
+
}
|
|
327
|
+
} else {
|
|
328
|
+
throw new TypeError('Content must be an `Element`, `DocumentFragment`, `HTMLDocument`, or `Response`.');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
*
|
|
334
|
+
* @param {string} description
|
|
335
|
+
*/
|
|
336
|
+
function setDescription(description = '') {
|
|
337
|
+
document.head.querySelectorAll(DESC_SELECTOR).forEach(el => el.content = description);
|
|
338
|
+
}
|
package/routes.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @type {Map<URLPattern, string>}
|
|
3
|
+
*/
|
|
4
|
+
const reg = new Map();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef RouteMatch
|
|
8
|
+
* @property {URLPatternResult|null} result The results of `pattern.exec(url)`
|
|
9
|
+
* @property {string|null} specifier The module specifier mapped to the URL
|
|
10
|
+
* @property {boolean} hasRegExpGroups
|
|
11
|
+
* @readonly
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @type RouteMatch
|
|
16
|
+
*/
|
|
17
|
+
const invalidMatchResult = Object.freeze({ result: null, specifier: null, hasRegExpGroups: false });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Finds the URLPattern that corresponds to the given URL
|
|
21
|
+
*
|
|
22
|
+
* @param {string} url
|
|
23
|
+
* @returns {URLPattern|undefined}
|
|
24
|
+
*/
|
|
25
|
+
export const getRegistryKey = url => reg.keys().find(pattern => pattern.test(url));
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
*
|
|
29
|
+
* @param {URLPattern} key
|
|
30
|
+
* @returns {string|null} The module specifier
|
|
31
|
+
*/
|
|
32
|
+
export const getRegistrySpecifier = key => reg.get(key);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
*
|
|
36
|
+
* @param {string} url
|
|
37
|
+
* @returns {RouteMatch}
|
|
38
|
+
*/
|
|
39
|
+
export function lookupRoute(url) {
|
|
40
|
+
const key = getRegistryKey(url);
|
|
41
|
+
|
|
42
|
+
if (key instanceof URLPattern) {
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
result: key.exec(url),
|
|
45
|
+
specifier: reg.get(key),
|
|
46
|
+
hasRegExpGroups: key.hasRegExpGroups,
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
return invalidMatchResult;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Registers module `specifier` to handle routes matching `pattern`
|
|
55
|
+
*
|
|
56
|
+
* @param {string|URLPattern} pattern The pattern to handle
|
|
57
|
+
* @param {string|URL} specifier The module to register to the pattern
|
|
58
|
+
*/
|
|
59
|
+
export function registerModule(pattern, specifier) {
|
|
60
|
+
if (typeof specifier !== 'string' && ! (specifier instanceof URL)) {
|
|
61
|
+
throw new TypeError(`Invalid specifier type ${typeof specifier}.`);
|
|
62
|
+
} else if (typeof pattern === 'string') {
|
|
63
|
+
reg.set(
|
|
64
|
+
URL.canParse(pattern) ? new URLPattern(pattern) : new URLPattern({ pathname: pattern }),
|
|
65
|
+
specifier.toString()
|
|
66
|
+
);
|
|
67
|
+
} else if (! (pattern instanceof URLPattern)) {
|
|
68
|
+
throw new TypeError(`Invalid pattner "${pattern}".`);
|
|
69
|
+
} else if (specifier instanceof URL) {
|
|
70
|
+
reg.set(pattern, specifier.href);
|
|
71
|
+
} else {
|
|
72
|
+
reg.set(pattern, specifier);
|
|
73
|
+
}
|
|
74
|
+
}
|