-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrouting.js
More file actions
155 lines (139 loc) · 5.16 KB
/
routing.js
File metadata and controls
155 lines (139 loc) · 5.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/**
* @file Routing module. Dynamically updates page content from fragments, replacing standard nagivation.
* @author Jordan Mann
* @license MIT
*/
import { nextEventLoop } from './util.js';
/**
* Matches a label comment's content, i.e. {{/label}}.
*/
const tagRegex = /^{{(\/?.+)}}$/;
/**
* Identify regions in a document or fragment by their label comments.
* The regions themselves do not include the labels, only their enclosed content.
* @param {Node} root in which to locate regions.
* @returns {Promise<Map<string, Range>>} located regions.
*/
async function locateRegions(root) {
/**
* Node iterator which scans through all label comment nodes in the page.
*/
const commentsIterator = document.createNodeIterator(root, NodeFilter.SHOW_COMMENT, {
/**
* Filter to only label nodes.
* @param {Comment} node the node to check.
* @returns { number } code corresponding to the filter result (accept or reject).
*/
acceptNode(node) {
return node.nodeValue?.match(tagRegex) !== null
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
let regions = new Map();
// take matching labels two at a time and convert them into ranges enclosing content.
for (
let [start, end] = [commentsIterator.nextNode(), commentsIterator.nextNode()];
start !== null && end !== null;
[start, end] = [commentsIterator.nextNode(), commentsIterator.nextNode()]
) {
const label = start.nodeValue?.match(tagRegex)?.[1];
if ('/' + label !== end.nodeValue?.match(tagRegex)?.[1]) {
throw new Error(`document's region tags don't match: '${start}' and '${end}'`);
}
const range = document.createRange();
range.setStartAfter(start);
range.setEndBefore(end);
regions.set(label, range);
}
return regions;
}
/**
* Load a page fragment for a given path and swap the current content with its own.
* @param {string} path the relative path to load.
* @todo error handling
*/
async function load(path) {
document.dispatchEvent(new CustomEvent('beforenavigate', { detail: { destination: path } }));
// checkme we are trying to wait for event listeners to these functions to finish
await nextEventLoop();
// fetch the fragment text.
let text = await (await fetch('/fragments/' + path)).text(); // todo fallback
// turn the fragment into DOM content.
const contextualFragment = document.createRange().createContextualFragment(text);
// find existing document regions on the page.
const docRegions = await locateRegions(document.documentElement);
docRegions.forEach((range) => range.deleteContents());
document.dispatchEvent(new CustomEvent('navigate', { detail: { destination: path } }));
await nextEventLoop();
// match the new fragment regions to cleared document regions with the same label.
const fragmentRegions = await locateRegions(contextualFragment);
fragmentRegions.forEach((range, label) => {
if (!docRegions.has(label)) {
throw new Error(`could not find document region for fragment region '${label}'`);
}
docRegions.get(label)?.insertNode(dynamify(range.cloneContents()));
});
document.dispatchEvent(new CustomEvent('endnavigate', { detail: { destination: path } }));
}
/**
* Convert hard links into dynamic ones that load() content instead of redirecting.
* @param {ParentNode} parent the parent element/fragment to transform.
* @returns {ParentNode} the transformed parent node.
*/
function dynamify(parent) {
parent.querySelectorAll('a').forEach((/** @type {HTMLAnchorElement} */ el) => {
// only make relative links dynamic.
// todo: maybe stat the fragment here?
if (new URL(el.href).origin === window.location.origin) {
el.layoutAddEventListener('click', async (ev) => {
// fixme
if (!(ev instanceof MouseEvent)) {
return;
}
if (ev.ctrlKey || ev.metaKey) {
return;
}
ev.preventDefault();
const path = new URL(el.href).pathname;
if (window.location.pathname !== path) {
console.log(
`🔀 ${window.location.pathname.replace('.html', '')} -> ${path.replace('.html', '')}`
);
load(path);
window.history.pushState(
{ scroll: { top: window.scrollY, left: window.scrollX, behavior: 'auto' } },
'',
el.href
);
} else {
// console.log(window.location.pathname, 'x', path);
}
});
}
});
return parent;
}
// handle history navigation (back, forward).
window.layoutAddEventListener('popstate', (e) => {
// fixme
if (!(e instanceof PopStateEvent)) {
return;
}
console.log(`🔀 popstate ${window.location.pathname}`);
load(window.location.pathname);
if (e.state !== null) {
const scrollOptions = Reflect.get(e.state, 'scroll');
if (scrollOptions !== undefined) {
window.scroll(scrollOptions);
}
}
});
// make all links dynamic, enabling routing.
dynamify(document.body);
console.log(`🔀 routing module ready.`);
// checkme
document.layoutAddEventListener('endnavigate', () => {
// enable components which require javascript.
document.querySelectorAll('[data-needs-js]').forEach((el) => el.removeAttribute('hidden'));
});