summaryrefslogtreecommitdiff
path: root/node_modules/@jet-app/app-store/tmp/src/common/util/generate-routes.js
blob: f370dbee772d7eee2244e327261c536685bdaab0 (plain)
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
import { isSome } from "@jet/environment";
import { URL } from "@jet/environment/util/urls";
import { Path, Parameters } from "../../foundation/network/url-constants";
import { normalizeLocale, deriveLocaleForUrl } from "../locale";
/**
 * Generate the routes property for a `RouteProvider` and a canonical URL factory
 * function for a {@link RoutableIntent} (non-detail page).
 *
 * `makeIntent` should be a function that accepts a single parameter,
 * `options: Omit<YourIntent, '$kind'>`, and returns the intent.
 *
 * `path` is the static part of the path. For example, in `'/us/browse?l=es'`,
 * `'/browse'` is the static part of the path. Path can contain multiple
 * segments like `'/foo/bar'`, but it does not support parameters like
 * `'/foo/{id}'`.
 *
 * `expectedQueryParameters` are the names of the query parameters for the routes
 * that also double as properties on the intent. This instructs the intent
 * builder on which properties to provide to `makeIntent`. For example,
 * `SearchResultsPageIntent`'s `RouteProvider` recognizes a `'term'` query param, which
 * is then stored in the intent as `{ term: 'valueFromParam' }`. For that case,
 * this array should be `['term']`. Put another way, this should be a list of the
 * non-optional (non-nullable, etc.) string properties on the intent:
 *
 * ```typescript
 * interface SearchResultsPageIntent extends RouteableIntent {
 *     $kind: 'SearchResultsPageIntent'; // not this one
 *     term: string; // this one should be included!
 *     facet: Opt<String>; // but it doesn't include this; it's optional
 * }
 * ```
 *
 * NOTE: If you receive a type error like `Type 'string' is not assignable
 * to type 'never'` for this argument, it means that your array of parameters
 * doesn't match the intent definition. In the above example, if we added a
 * `originalTerm: string` to the intent, then passing ['term'] would fail,
 * because 'originalTerm' is missing. Adding it does mean, however, that you
 * expect users to be able to pass it as a query parameter
 * (ex. /search?term=foo&originalTerm=bar). If that's unintended, you may want
 * to consider making this property Opt<> (and also maybe ?).
 *
 * Usage Example: For a contrived intent `FooIntent`,
 *
 * ```typescript
 * interface FooIntent extends RouteableIntent {
 *     $kind: 'FooIntent';
 *     bar: string;
 * }
 * ```
 *
 * If we expect users to be able to route to this intent via `/us/foo?bar=baz`
 * or `/foo?bar=baz&l=es`, then in the IntentController's file:
 *
 * ```typescript
 * const {
 *     routes,
 *     makeCanonicalUrl,
 * } = generateRoutes(makeFooIntent, '/foo', ['bar']);
 * ```
 *
 * And then, `routes` can be used in the `RouteProvider`:
 *
 * ```typescript
 * export const FooIntentController: RouteProvider<FooIntent> & ... = {
 *     routes,
 *
 *     // ...
 * };
 * ```
 *
 * And then in perform, when returning a `Page`, append the `canonicalURL`:
 *
 * ```typescript
 * async perform(intent: FooIntent, objectGraph: ObjectGraph): Promise<Page> {
 *     // ...
 *     return {
 *         ...page,
 *         canonicalURL: makeCanonicalUrl(intent),
 *     };
 * },
 * ```
 */
export function generateRoutes(makeIntent, path, requiredQueryParameters = [], extras) {
    const ruleExtras = { exclusions: extras === null || extras === void 0 ? void 0 : extras.exclusions };
    const queryParameters = requiredQueryParameters.slice();
    if (extras === null || extras === void 0 ? void 0 : extras.optionalQuery) {
        queryParameters.push(...extras.optionalQuery.map((p) => `${p}?`));
    }
    return {
        routes(objectGraph) {
            var _a;
            return [
                {
                    rules: [
                        {
                            ...ruleExtras,
                            path,
                            query: [`${Parameters.language}?`, ...queryParameters],
                        },
                        {
                            ...ruleExtras,
                            path: `/{${Path.storeFront}}${path}`,
                            query: [`${Parameters.language}?`, ...queryParameters],
                        },
                        ...((_a = extras === null || extras === void 0 ? void 0 : extras.extraRules) !== null && _a !== void 0 ? _a : []),
                    ],
                    handler(_, parameters) {
                        const { [Path.storeFront]: storefront, [Parameters.language]: language, ...rest } = parameters;
                        const typedRestParams = rest;
                        return makeIntent({
                            ...typedRestParams,
                            ...normalizeLocale(objectGraph, {
                                storefront,
                                language,
                            }),
                        });
                    },
                },
            ];
        },
        makeCanonicalUrl(objectGraph, intent) {
            const url = buildBaseUrl(objectGraph, path, intent);
            // Replace dynamic segments with `Intent` properties
            const pathComponents = url.pathComponents();
            for (const [i, pathComponent] of pathComponents.entries()) {
                if (pathComponent.startsWith("{") && pathComponent.endsWith("}")) {
                    const variableInPathComponent = pathComponent.substring(1, pathComponent.length - 1);
                    pathComponents[i] = intent[variableInPathComponent];
                }
            }
            url.set("pathname", "/" + pathComponents.join("/"));
            // Set query params
            const sortedQueryParameters = requiredQueryParameters.slice();
            // Sorted for consistent order
            sortedQueryParameters.sort((a, b) => a.localeCompare(b));
            for (const queryParameter of sortedQueryParameters) {
                if (!(queryParameter in intent)) {
                    throw new Error(`expected queryParmeters to contain: ${queryParameter}`);
                }
                const value = intent[queryParameter];
                // NOTE: TypeScript is unfortunately not able to see that
                // parameters[queryParameter] will always be string. We prove
                // this in the type. queryParameters is required to contain the
                // string names of all keys on I with a string value (see
                // RequiredIntentOptions<I>). This escape hatch is preferable
                // within this function as it means that the function is
                // externally typesafe. If a user forgets to pass in params,
                // there will be a type error.
                url.param(queryParameter, value);
            }
            if (extras === null || extras === void 0 ? void 0 : extras.optionalQuery) {
                const optionalParams = extras.optionalQuery.slice();
                optionalParams.sort((a, b) => a.localeCompare(b));
                optionalParams.forEach((query) => {
                    const value = intent[query];
                    if (value) {
                        url.param(query, value);
                    }
                });
            }
            return url.toString();
        },
    };
}
/**
 * Builds the basis for a "canonical URL" given a {@linkcode path} and {@linkcode locale}
 *
 * The locale data will be encoded into the resulting {@linkcode URL}; other "dynamic segments"
 * within {@linkcode path} are expected to be replaced separately
 */
function buildBaseUrl(objectGraph, path, locale) {
    const url = new URL("https://apps.apple.com");
    const { storefront, language } = deriveLocaleForUrl(objectGraph, locale);
    url.path(storefront);
    // Actual path (ex. browse in https://apps.apple.com/us/browse)
    // substr(1), because URL.path doesn't expect a leading slash
    url.path(path.substr(1));
    if (isSome(language)) {
        url.param("l", language);
    }
    return url;
}
//# sourceMappingURL=generate-routes.js.map