@ethlete/query 6.0.0-next.23 ā 6.0.0-next.24
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 +45 -0
- package/fesm2022/ethlete-query.mjs +1333 -117
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/generators/deprecate-legacy-queries/migration.js +77 -0
- package/generators/deprecate-legacy-queries/schema.json +26 -0
- package/generators/generators.json +5 -0
- package/generators/migrate-to-query-v3/legacy-prepare-migration.js +111 -18
- package/generators/migrate-to-query-v3/legacy-query-creator-migration.js +2 -2
- package/generators/migrate-to-query-v3/shared.js +43 -0
- package/package.json +1 -1
- package/types/ethlete-query.d.ts +1187 -15
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { formatFiles } from '@nx/devkit';
|
|
2
|
+
import * as ts from 'typescript';
|
|
3
|
+
import { createMigrationScope } from '../migrate-to-query-v3/migration-scope.js';
|
|
4
|
+
import { addJsDocTag, createSourceFile, legacyQueryDeprecationTag } from '../migrate-to-query-v3/shared.js';
|
|
5
|
+
const CREATOR_FN = 'createLegacyQueryCreator';
|
|
6
|
+
const isMigratableFile = (filePath) => /\.(ts|mts|cts)$/.test(filePath) && !filePath.endsWith('.d.ts');
|
|
7
|
+
const creatorNameOf = (call, sourceFile) => {
|
|
8
|
+
const [options] = call.arguments;
|
|
9
|
+
if (!options || !ts.isObjectLiteralExpression(options)) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && candidate.name.getText(sourceFile) === 'creator');
|
|
13
|
+
if (!property || !ts.isIdentifier(property.initializer)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return property.initializer.text;
|
|
17
|
+
};
|
|
18
|
+
const collectWrappers = (sourceFile) => {
|
|
19
|
+
const wrappers = [];
|
|
20
|
+
const visit = (node) => {
|
|
21
|
+
if (ts.isVariableDeclaration(node) &&
|
|
22
|
+
node.initializer &&
|
|
23
|
+
ts.isCallExpression(node.initializer) &&
|
|
24
|
+
ts.isIdentifier(node.initializer.expression) &&
|
|
25
|
+
node.initializer.expression.text === CREATOR_FN) {
|
|
26
|
+
const statement = node.parent.parent;
|
|
27
|
+
// A wrapper declared inside a function body has no importable call sites to strike through.
|
|
28
|
+
if (ts.isVariableStatement(statement) && ts.isSourceFile(statement.parent)) {
|
|
29
|
+
wrappers.push({ statement, creatorName: creatorNameOf(node.initializer, sourceFile) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
ts.forEachChild(node, visit);
|
|
33
|
+
};
|
|
34
|
+
visit(sourceFile);
|
|
35
|
+
return wrappers;
|
|
36
|
+
};
|
|
37
|
+
export const deprecateLegacyQueries = (tree, scope) => {
|
|
38
|
+
let deprecatedWrappers = 0;
|
|
39
|
+
let touchedFiles = 0;
|
|
40
|
+
scope.visit(tree, (filePath) => {
|
|
41
|
+
if (!isMigratableFile(filePath))
|
|
42
|
+
return;
|
|
43
|
+
const content = tree.read(filePath, 'utf-8');
|
|
44
|
+
if (!content?.includes(CREATOR_FN))
|
|
45
|
+
return;
|
|
46
|
+
const sourceFile = createSourceFile(content, filePath);
|
|
47
|
+
const wrappers = collectWrappers(sourceFile);
|
|
48
|
+
let nextContent = content;
|
|
49
|
+
let tagged = 0;
|
|
50
|
+
// Back to front, so an insertion never shifts the position the next wrapper was parsed at.
|
|
51
|
+
for (const wrapper of wrappers.sort((a, b) => b.statement.getStart(sourceFile) - a.statement.getStart(sourceFile))) {
|
|
52
|
+
const updated = addJsDocTag(nextContent, sourceFile, wrapper.statement, legacyQueryDeprecationTag(wrapper.creatorName));
|
|
53
|
+
if (updated === null)
|
|
54
|
+
continue;
|
|
55
|
+
nextContent = updated;
|
|
56
|
+
tagged += 1;
|
|
57
|
+
}
|
|
58
|
+
if (tagged === 0)
|
|
59
|
+
return;
|
|
60
|
+
tree.write(filePath, nextContent);
|
|
61
|
+
deprecatedWrappers += tagged;
|
|
62
|
+
touchedFiles += 1;
|
|
63
|
+
});
|
|
64
|
+
return { deprecatedWrappers, touchedFiles };
|
|
65
|
+
};
|
|
66
|
+
export default async function migrate(tree, schema) {
|
|
67
|
+
console.log('\nš Deprecating legacy query wrappers...');
|
|
68
|
+
const scope = createMigrationScope(tree, schema);
|
|
69
|
+
console.log(` Scope: ${scope.describe()}`);
|
|
70
|
+
const { deprecatedWrappers, touchedFiles } = deprecateLegacyQueries(tree, scope);
|
|
71
|
+
console.log(` Tagged ${deprecatedWrappers} wrapper${deprecatedWrappers === 1 ? '' : 's'} in ${touchedFiles} file${touchedFiles === 1 ? '' : 's'}.`);
|
|
72
|
+
if (!schema.skipFormat) {
|
|
73
|
+
await formatFiles(tree);
|
|
74
|
+
}
|
|
75
|
+
console.log('\nā
Legacy query wrappers deprecated successfully!');
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=migration.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "deprecate-legacy-queries",
|
|
4
|
+
"title": "Deprecate legacy query wrappers",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"projects": {
|
|
8
|
+
"type": "array",
|
|
9
|
+
"items": { "type": "string" },
|
|
10
|
+
"description": "Nx projects to migrate. Defaults to every project in the workspace.",
|
|
11
|
+
"alias": "project"
|
|
12
|
+
},
|
|
13
|
+
"include": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "type": "string" },
|
|
16
|
+
"description": "Path prefixes to migrate, relative to the workspace root (e.g. 'libs/queries'). Unioned with `projects`. Defaults to the whole workspace.",
|
|
17
|
+
"alias": "includePath"
|
|
18
|
+
},
|
|
19
|
+
"skipFormat": {
|
|
20
|
+
"type": "boolean",
|
|
21
|
+
"description": "Skip formatting files after migration",
|
|
22
|
+
"default": false
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": []
|
|
26
|
+
}
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"schema": "./prep-for-query-v3/schema.json",
|
|
16
16
|
"description": "Update code to prepare for migration to query v3"
|
|
17
17
|
},
|
|
18
|
+
"deprecate-legacy-queries": {
|
|
19
|
+
"factory": "./deprecate-legacy-queries/migration",
|
|
20
|
+
"schema": "./deprecate-legacy-queries/schema.json",
|
|
21
|
+
"description": "Mark existing createLegacyQueryCreator wrappers as @deprecated so their call sites surface as still on v2"
|
|
22
|
+
},
|
|
18
23
|
"migrate-query-opt-in-features": {
|
|
19
24
|
"factory": "./migrate-query-opt-in-features/migration",
|
|
20
25
|
"schema": "./migrate-query-opt-in-features/schema.json",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import { createSourceFile, ensureAngularCoreImports, getIndentation, getLineNumber, getLineNumberFromPosition, } from './shared.js';
|
|
3
|
+
/** Contexts that already have an injector of their own, so the call site needs no rewrite. */
|
|
4
|
+
const CONTEXTS_WITH_INJECTOR = [
|
|
5
|
+
'queryComputed',
|
|
6
|
+
'injection-context',
|
|
7
|
+
'class-field',
|
|
8
|
+
'constructor',
|
|
9
|
+
];
|
|
3
10
|
export const migrateLegacyPrepareCalls = (tree, report, scope) => {
|
|
4
11
|
console.log('\nš Checking for legacy query creator usages...');
|
|
5
12
|
const allUsages = [];
|
|
@@ -98,32 +105,88 @@ const findLegacyCreatorNames = (sourceFile) => {
|
|
|
98
105
|
visit(sourceFile);
|
|
99
106
|
return names;
|
|
100
107
|
};
|
|
108
|
+
/** Helpers that run their callback inside an injection context, so a `prepare()` in one needs nothing. */
|
|
109
|
+
const CONTEXT_PROVIDING_CALLEES = new Set([
|
|
110
|
+
'runInInjectionContext',
|
|
111
|
+
'queryComputed',
|
|
112
|
+
'queryComputedTillTruthy',
|
|
113
|
+
'queryArrayComputed',
|
|
114
|
+
]);
|
|
115
|
+
/**
|
|
116
|
+
* Array methods, which call back synchronously in the caller's own context. Matched only on a property
|
|
117
|
+
* access (`items.map(ā¦)`) - the bare-identifier form is an RxJS operator, whose callback runs later.
|
|
118
|
+
*/
|
|
119
|
+
const TRANSPARENT_ARRAY_METHODS = new Set([
|
|
120
|
+
'map',
|
|
121
|
+
'flatMap',
|
|
122
|
+
'filter',
|
|
123
|
+
'forEach',
|
|
124
|
+
'find',
|
|
125
|
+
'findLast',
|
|
126
|
+
'findIndex',
|
|
127
|
+
'some',
|
|
128
|
+
'every',
|
|
129
|
+
'reduce',
|
|
130
|
+
'reduceRight',
|
|
131
|
+
'sort',
|
|
132
|
+
'flat',
|
|
133
|
+
]);
|
|
134
|
+
const isCallbackFunction = (node) => ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node);
|
|
135
|
+
/** The call a function literal is being passed to, if it is an argument rather than a value. */
|
|
136
|
+
const getCallbackHost = (node) => {
|
|
137
|
+
const parent = node.parent;
|
|
138
|
+
if (!ts.isCallExpression(parent) || !parent.arguments.some((argument) => argument === node)) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
return parent;
|
|
142
|
+
};
|
|
143
|
+
const isContextProvidingCallback = (node) => {
|
|
144
|
+
const host = getCallbackHost(node);
|
|
145
|
+
if (!host) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (ts.isIdentifier(host.expression)) {
|
|
149
|
+
return CONTEXT_PROVIDING_CALLEES.has(host.expression.text);
|
|
150
|
+
}
|
|
151
|
+
return ts.isPropertyAccessExpression(host.expression) && host.expression.name.text === 'runInContext';
|
|
152
|
+
};
|
|
153
|
+
const isTransparentCallback = (node) => {
|
|
154
|
+
const host = getCallbackHost(node);
|
|
155
|
+
return (!!host && ts.isPropertyAccessExpression(host.expression) && TRANSPARENT_ARRAY_METHODS.has(host.expression.name.text));
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Classifies the **innermost** function boundary the call sits behind, not the first structural ancestor.
|
|
159
|
+
* The difference is the whole point: `effect(() => prepare())` in a constructor and
|
|
160
|
+
* `computed(() => prepare())` at a class field both run long after the context that created them, so they
|
|
161
|
+
* need an injector just like a plain method does - reading the constructor or the field as the answer
|
|
162
|
+
* skipped exactly the call sites that go on to throw ET950.
|
|
163
|
+
*/
|
|
101
164
|
const determineUsageContext = (node) => {
|
|
102
165
|
let current = node;
|
|
103
166
|
while (current) {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
ts.isIdentifier(
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
if (isCallbackFunction(current)) {
|
|
168
|
+
if (isContextProvidingCallback(current)) {
|
|
169
|
+
const host = getCallbackHost(current);
|
|
170
|
+
const callee = host && ts.isIdentifier(host.expression) ? host.expression.text : undefined;
|
|
171
|
+
return callee?.startsWith('query') ? 'queryComputed' : 'injection-context';
|
|
172
|
+
}
|
|
173
|
+
if (!isTransparentCallback(current)) {
|
|
174
|
+
return isInsideClass(current) ? 'method' : 'function';
|
|
110
175
|
}
|
|
111
176
|
}
|
|
177
|
+
// Reached without crossing a callback: the call really does run while the class is being built, which
|
|
178
|
+
// is an injection context.
|
|
112
179
|
if (ts.isConstructorDeclaration(current)) {
|
|
113
180
|
return 'constructor';
|
|
114
181
|
}
|
|
115
|
-
if (ts.isMethodDeclaration(current)
|
|
182
|
+
if (ts.isMethodDeclaration(current) ||
|
|
183
|
+
ts.isGetAccessorDeclaration(current) ||
|
|
184
|
+
ts.isSetAccessorDeclaration(current)) {
|
|
116
185
|
return 'method';
|
|
117
186
|
}
|
|
118
187
|
if (ts.isPropertyDeclaration(current)) {
|
|
119
188
|
return 'class-field';
|
|
120
189
|
}
|
|
121
|
-
if (ts.isFunctionDeclaration(current)) {
|
|
122
|
-
return 'function';
|
|
123
|
-
}
|
|
124
|
-
if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && !isInsideClass(current)) {
|
|
125
|
-
return 'function';
|
|
126
|
-
}
|
|
127
190
|
current = current.parent;
|
|
128
191
|
}
|
|
129
192
|
return 'unknown';
|
|
@@ -177,7 +240,7 @@ const findOrCreateInjectorMembers = (tree, usages) => {
|
|
|
177
240
|
const classInjectors = new Map();
|
|
178
241
|
const usagesByFile = new Map();
|
|
179
242
|
usages.forEach((usage) => {
|
|
180
|
-
if (
|
|
243
|
+
if (CONTEXTS_WITH_INJECTOR.includes(usage.context)) {
|
|
181
244
|
return;
|
|
182
245
|
}
|
|
183
246
|
if (!usagesByFile.has(usage.filePath)) {
|
|
@@ -342,7 +405,7 @@ const transformPrepareCallsInFile = (content, classes, pollingInfo) => {
|
|
|
342
405
|
return;
|
|
343
406
|
}
|
|
344
407
|
const context = determineUsageContext(node);
|
|
345
|
-
if (
|
|
408
|
+
if (CONTEXTS_WITH_INJECTOR.includes(context)) {
|
|
346
409
|
ts.forEachChild(node, visit);
|
|
347
410
|
return;
|
|
348
411
|
}
|
|
@@ -535,7 +598,7 @@ const transformSinglePrepareCall = (callNode, injectorReference, sourceFile, pol
|
|
|
535
598
|
}
|
|
536
599
|
nextArgumentProperties.push(`injector: ${injectorReference}`);
|
|
537
600
|
const queryVariableName = findQueryVariableNameForPrepareCall(callNode, sourceFile);
|
|
538
|
-
const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo);
|
|
601
|
+
const addDestroyOnResponse = shouldAddDestroyOnResponse(queryVariableName, pollingInfo, isDiscardedPrepareCall(callNode));
|
|
539
602
|
if (existingConfig && ts.isObjectLiteralExpression(existingConfig.initializer)) {
|
|
540
603
|
const configProperties = [];
|
|
541
604
|
existingConfig.initializer.properties.forEach((property) => {
|
|
@@ -763,9 +826,39 @@ const detectPollingInTemplate = (content, filePath, pollingInfo) => {
|
|
|
763
826
|
}
|
|
764
827
|
}
|
|
765
828
|
};
|
|
766
|
-
|
|
829
|
+
/**
|
|
830
|
+
* Whether the prepared query is thrown away - `legacyGetUsers.prepare().execute()` as a statement, with
|
|
831
|
+
* nothing holding on to it. Chained `.poll()` disqualifies it: that query is meant to keep running.
|
|
832
|
+
*/
|
|
833
|
+
const isDiscardedPrepareCall = (callNode) => {
|
|
834
|
+
let current = callNode;
|
|
835
|
+
while (current.parent) {
|
|
836
|
+
const parent = current.parent;
|
|
837
|
+
if (ts.isParenthesizedExpression(parent) || ts.isAwaitExpression(parent) || ts.isNonNullExpression(parent)) {
|
|
838
|
+
current = parent;
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === current) {
|
|
842
|
+
if (parent.name.text === 'poll') {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
845
|
+
current = parent;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
// Only a chained call keeps it discarded; being an *argument* means something else receives the query.
|
|
849
|
+
if (ts.isCallExpression(parent) && parent.expression === current) {
|
|
850
|
+
current = parent;
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
return ts.isExpressionStatement(parent);
|
|
854
|
+
}
|
|
855
|
+
return false;
|
|
856
|
+
};
|
|
857
|
+
const shouldAddDestroyOnResponse = (queryVariableName, pollingInfo, isDiscarded) => {
|
|
767
858
|
if (!queryVariableName) {
|
|
768
|
-
|
|
859
|
+
// A discarded query is the one case where nothing else can clean it up: no variable for a container
|
|
860
|
+
// to hold, no caller to destroy it. That is exactly what `destroyOnResponse` is for.
|
|
861
|
+
return isDiscarded;
|
|
769
862
|
}
|
|
770
863
|
return !pollingInfo.get(queryVariableName)?.hasPolling;
|
|
771
864
|
};
|
|
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
|
|
|
2
2
|
import { collectV2BearerAuthConfigs, renderAuthProviderBody } from './auth-provider-scaffold.js';
|
|
3
3
|
import { createModuleGraph } from './module-graph.js';
|
|
4
4
|
import { pruneUnusedNamedImports, renameImportedReferences } from './rename-symbols.js';
|
|
5
|
-
import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, } from './shared.js';
|
|
5
|
+
import { capitalizeFirstLetter, createSourceFile, ensureConfigSuffix, ensureImportFromEthleteCore, ensureImportFromQuery, legacyQueryDeprecationTag, } from './shared.js';
|
|
6
6
|
const LEGACY_HTTP_METHOD = {
|
|
7
7
|
GET: 'get',
|
|
8
8
|
POST: 'post',
|
|
@@ -443,7 +443,7 @@ const getTypeParameter = (creator) => {
|
|
|
443
443
|
const generateLegacyWrapper = (creator) => {
|
|
444
444
|
// `name` is what lets a `prepare()` called outside an injection context name itself in the error
|
|
445
445
|
// instead of surfacing as a bare NG0203.
|
|
446
|
-
return
|
|
446
|
+
return `/**\n * ${legacyQueryDeprecationTag(creator.name)}\n */\nexport const ${toLegacyName(creator.name)} = createLegacyQueryCreator({ name: '${toLegacyName(creator.name)}', creator: ${creator.name} });`;
|
|
447
447
|
};
|
|
448
448
|
const createAuthProviders = ({ tree, authProvidersNeeded, report, v2Configs, creatorToClient, }) => {
|
|
449
449
|
const createdProviders = [];
|
|
@@ -96,4 +96,47 @@ export const getLineNumber = (node, sourceFile) => {
|
|
|
96
96
|
return line + 1;
|
|
97
97
|
};
|
|
98
98
|
export const getLineNumberFromPosition = (content, position) => content.slice(0, position).split('\n').length;
|
|
99
|
+
const MIGRATION_GUIDE_URL = 'https://ethlete-sdk-docs.web.app/query/migrating-from-v2';
|
|
100
|
+
/**
|
|
101
|
+
* The tag every `createLegacyQueryCreator(ā¦)` wrapper carries. The wrapper is a migration
|
|
102
|
+
* scaffold, so marking it is what turns "which call sites are still on v2" from a grep into
|
|
103
|
+
* strikethrough in the editor.
|
|
104
|
+
*/
|
|
105
|
+
export const legacyQueryDeprecationTag = (creatorName) => `@deprecated Legacy (v2) query wrapper. Migrate the call sites to ${creatorName ? `\`${creatorName}\`` : 'the query creator it wraps'} and delete this wrapper - see ${MIGRATION_GUIDE_URL}.`;
|
|
106
|
+
/**
|
|
107
|
+
* Adds a tag to `node`'s JSDoc, creating the block when there is none. Returns `null` when the
|
|
108
|
+
* tag is already there, so a caller can tell "nothing to do" from "rewritten".
|
|
109
|
+
*
|
|
110
|
+
* Leading `//` comments stay glued to the node - an `eslint-disable-next-line` only works from
|
|
111
|
+
* the line directly above.
|
|
112
|
+
*/
|
|
113
|
+
export const addJsDocTag = (content, sourceFile, node, tag) => {
|
|
114
|
+
const jsDocs = node.jsDoc ?? [];
|
|
115
|
+
const existing = jsDocs[jsDocs.length - 1];
|
|
116
|
+
const tagName = /^@\w+/.exec(tag)?.[0];
|
|
117
|
+
if (existing && tagName && new RegExp(`${tagName}\\b`).test(existing.getText())) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
if (existing) {
|
|
121
|
+
const start = existing.getStart(sourceFile);
|
|
122
|
+
const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
|
|
123
|
+
const text = existing.getText();
|
|
124
|
+
const body = text.slice(0, text.lastIndexOf('*/')).replace(/\s+$/, '');
|
|
125
|
+
// A one-line `/** ⦠*/` has to become a block before a tag line can be appended to it.
|
|
126
|
+
const opened = body.includes('\n') ? body : `/**\n${indentation} * ${body.slice(3).trim()}`;
|
|
127
|
+
return (content.slice(0, start) +
|
|
128
|
+
`${opened}\n${indentation} *\n${indentation} * ${tag}\n${indentation} */` +
|
|
129
|
+
content.slice(existing.getEnd()));
|
|
130
|
+
}
|
|
131
|
+
let start = node.getStart(sourceFile, false);
|
|
132
|
+
for (const comment of (ts.getLeadingCommentRanges(content, node.getFullStart()) ?? []).reverse()) {
|
|
133
|
+
if (comment.kind !== ts.SyntaxKind.SingleLineCommentTrivia)
|
|
134
|
+
break;
|
|
135
|
+
if (!/^\n[ \t]*$/.test(content.slice(comment.end, start)))
|
|
136
|
+
break;
|
|
137
|
+
start = comment.pos;
|
|
138
|
+
}
|
|
139
|
+
const indentation = ' '.repeat(sourceFile.getLineAndCharacterOfPosition(start).character);
|
|
140
|
+
return (content.slice(0, start) + `/**\n${indentation} * ${tag}\n${indentation} */\n${indentation}` + content.slice(start));
|
|
141
|
+
};
|
|
99
142
|
//# sourceMappingURL=shared.js.map
|