/
actions.ts
305 lines (276 loc) · 10.3 KB
/
actions.ts
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
import TinyRouter, { Packages } from "../types";
import { warn, error, observe, batch } from "frontity";
import { isError, isRedirection } from "@frontity/source";
import { Derived } from "frontity/types";
import { Data } from "@frontity/source/types";
/**
* This is an experimental function to be able to resolve the types of derived
* state (Derived type) and Actions (Action type). It is not complete and only
* works for this case, but it is something that if proven useful could be
* exposed in "frontity/types". It is based on some tips of this talk:
* https://www.youtube.com/watch?v=wNsKJMSqtAk.
*
* @param derivedOrAction - The definition of the action.
* @returns The same value in JavaScript, but the resolved value in TypeScript.
*/
const resolved = <T extends (...args: any) => any>(
derivedOrAction: T
): ReturnType<T> => derivedOrAction as any;
/**
* Set the URL.
*
* @param link - The URL that will replace the current one. It can be a path
* like `/category/nature/`, a path that includes the page
* `/category/nature/page/2` or the full URL `https://site.com/category/nature`.
*
* @param options - An optional configuration object that can contain:
* - `method` "push" | "replace" (default: "push").
*
* The method used in the action. "push" corresponds to window.history.pushState
* and "replace" to window.history.replaceState.
*
* - `state` - An object that will be saved in window.history.state. This object
* is recovered when the user go back and forward using the browser buttons.
*
* @example
* ```
* const Link = ({ actions, children, link }) => {
* const onClick = (event) => {
* event.preventDefault();
* actions.router.set(link);
* };
*
* return (
* <a href={link} onClick={onClick}>
* {children}
* </a>
* );
* };
* ```
* @returns Void.
*/
export const set: TinyRouter["actions"]["router"]["set"] = ({
state,
actions,
libraries,
}) => (link, options = {}): void => {
// Normalize the link.
if (libraries.source && libraries.source.normalize)
link = libraries.source.normalize(link);
// If the link hasn't changed, do nothing.
if (state.router.link === link) return;
// Clone the state that we are going to use for `window.history` because it
// cannot contain proxies.
const historyState = JSON.parse(JSON.stringify(options.state || {}));
// If the data is a redirection, then we set the link to the location.
// The redirections are stored in source.data just like any other data.
const data = state.source?.get(link);
if (data && data.isReady && isRedirection(data)) {
if (data.isExternal) {
window.replaceLocation(data.location);
} else {
// If the link is internal, we have to discard the domain.
const { pathname, search, hash } = new URL(
data.location,
"https://dummy-domain.com"
);
// If there is a link normalize, we have to use it.
if (libraries.source && libraries.source.normalize)
link = libraries.source.normalize(pathname + search + hash);
else link = pathname + search + hash;
}
}
// If we are in the client, update `window.history` and fetch the link.
if (state.frontity.platform === "client") {
if (!options.method || options.method === "push")
window.history.pushState(historyState, "", link);
else if (options.method === "replace")
window.history.replaceState(historyState, "", link);
else if (options.method !== "pop") {
// Throw an error if another method is used. We support "pop" internally
// for popstate events.
error(
`The method ${options.method} is not supported by actions.router.set.`
);
}
// If `autoFetch` is on, do the fetch.
if (state.router.autoFetch) actions.source?.fetch(link);
}
// Finally, set the `state.router.link` property to the new value.
batch(() => {
state.router.previous = state.router.link;
state.router.link = link;
state.router.state = historyState;
});
};
/**
* Replace the value of `state.router.state` with the give object.
*
* This implementation also executes a `window.history.replaceState()` with that
* object.
*
* @param historyState - The history state object.
* @returns Void.
*/
export const updateState: TinyRouter["actions"]["router"]["updateState"] = ({
state,
}) => (historyState: Record<string, unknown>) => {
// Clone the state to make sure we don't leak proxies.
const cloned = JSON.parse(JSON.stringify(historyState));
state.router.state = cloned;
window.history.replaceState(cloned, "");
};
/**
* Initilization of the router.
*
* @param store - The Frontity store.
*/
export const init: TinyRouter["actions"]["router"]["init"] = ({
state,
actions,
libraries,
}) => {
if (state.frontity.platform === "server") {
// Populate the router info with the initial path and page.
state.router.link = libraries.source?.normalize
? libraries.source.normalize(state.frontity.initialLink)
: state.frontity.initialLink;
} else {
// Wrap `window.replace.location` so we can mock it in the e2e tests.
// This is required because `window.location` is protected by the browser
// and can't be modified.
window.replaceLocation =
window.replaceLocation || window.location.replace.bind(window.location);
// Observe the current data object. If it is ever a redirection, replace the
// current link with the new one.
observe(() => {
const data = state.source?.get(state.router.link);
if (data && isRedirection(data)) {
// If the redirection is external, redirect to the full URL.
if (data.isExternal) {
window.replaceLocation(data.location);
} else {
// If the redirection is internal, use actions.router.set to switch
// to the new redirection.
actions.router.set(data.location, {
// Use "replace" to keep browser history consistent.
method: "replace",
// Keep the same history.state that the old link had. We have to
// stringfy and parse the object because window.history.replaceState()
// does not accept a Proxy.
state: state.router.state,
});
}
}
});
// The link stored in `state.router.link` may be wrong if the server changes
// it in some cases (see https://github.com/frontity/frontity/issues/623).
// For that reason, it is replaced with the current link in the browser.
// We should remove it once we have Frontity Hooks/Filters.
// Get the browser URL to remove the Frontity options.
const browserURL = new URL(location.href);
Array.from(browserURL.searchParams.keys()).forEach((key) => {
if (key.startsWith("frontity_")) browserURL.searchParams.delete(key);
});
// Get the browser link.
const browserLink =
browserURL.pathname + browserURL.search + browserURL.hash;
// Normalize it.
const link = libraries.source?.normalize
? libraries.source.normalize(browserLink)
: browserLink;
// Add the state to the browser history and replace the link.
window.history.replaceState(
JSON.parse(JSON.stringify(state.router.state)),
"",
link
);
// We have to compare the `initalLink` with `browserLink` because we have
// normalized the `link` at this point and `initialLink` is not normalized.
if (browserLink !== state.frontity.initialLink) {
if (state.source) {
/**
* Derived state pointing to the initial data object.
*
* @param store - The Frontity store.
* @returns The initial data object.
*/
const initialDataObject: Derived<Packages, Data> = ({ state }) =>
state.source.get(state.frontity.initialLink);
state.source.data[link] = resolved(initialDataObject);
}
// Update the value of `state.router.link`.
state.router.link = link;
}
// Listen to changes in history.
window.addEventListener("popstate", (event) => {
if (event.state) {
actions.router.set(
location.pathname + location.search + location.hash,
// We are casting types here because `pop` is used only internally,
// therefore we don't want to expose it in the types for users.
{ method: "pop", state: event.state } as any
);
}
});
}
};
/**
* Implementation of the `beforeSSR()` Frontity action as used by the
* tiny-router.
*
* @param ctx - The context of the Koa application.
*
* @returns Void.
*/
export const beforeSSR: TinyRouter["actions"]["router"]["beforeSSR"] = ({
state,
actions,
}) => async ({ ctx }) => {
// If autoFetch is disabled, there is nothing to do.
if (!state.router.autoFetch) {
return;
}
// Because Frontity is a modular framework, it could happen that a source
// package like `@frontity/wp-source` has not been installed but the user is
// trying to use autoFetch option, which requires it.
if (!actions.source || !actions.source.fetch || !state.source.get) {
warn("You are trying to use autoFetch but no source package is installed.");
return;
}
// Fetch the current link.
await actions.source.fetch(state.router.link);
const data = state.source.get(state.router.link);
// Check if the link has a redirection.
if (data && isRedirection(data)) {
// If the redirection is external, just redirect to the full URL here.
if (data.isExternal) {
ctx.status = data.redirectionStatus;
ctx.redirect(data.location);
return;
}
// Recover all the missing query params from the original URL. This is
// required because we remove the query params that start with `frontity_`.
const location = new URL(data.location, "https://dummy-domain.com");
ctx.URL.searchParams.forEach((value, key) => {
if (!location.searchParams.has(key))
location.searchParams.append(key, value);
});
// Set the correct status for the redirection. It could be a 301, 302, 307
// or 308.
ctx.status = data.redirectionStatus;
// 30X redirections need the be absolute, so we add the Frontity URL.
const redirectionURL =
state.frontity.url.replace(/\/$/, "") +
location.pathname +
location.search +
location.hash;
ctx.redirect(redirectionURL);
return;
}
if (isError(data)) {
// If there was an error, return the proper status.
ctx.status = data.errorStatus;
return;
}
};