@flarum/cli 3.0.12 → 3.0.14

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.
Files changed (66) hide show
  1. package/README.md +33 -1
  2. package/boilerplate/skeleton/extension/composer.json +4 -0
  3. package/boilerplate/skeleton/extension/js/package.json +5 -5
  4. package/boilerplate/skeleton/extension/js/tsconfig.json +3 -0
  5. package/boilerplate/skeleton/extension/js/tsconfig.test.json +1 -0
  6. package/boilerplate/stubs/backend/advanced-api-resource.php +3 -3
  7. package/boilerplate/stubs/backend/model-factory.php +17 -0
  8. package/boilerplate/stubs/backend/model.php +3 -0
  9. package/lib/base-command.d.ts +21 -3
  10. package/lib/base-command.js +52 -5
  11. package/lib/commands/make/backend/model.js +10 -0
  12. package/lib/commands/upgrade/2.0.d.ts +43 -0
  13. package/lib/commands/upgrade/2.0.js +128 -2
  14. package/lib/steps/gen-ext-scaffolder.d.ts +5 -0
  15. package/lib/steps/gen-ext-scaffolder.js +17 -2
  16. package/lib/steps/stubs/backend/advanced-api-resource.js +1 -1
  17. package/lib/steps/stubs/backend/model-factory.d.ts +24 -0
  18. package/lib/steps/stubs/backend/model-factory.js +33 -0
  19. package/lib/steps/upgrade/twopointoh/backend/email-views.js +22 -30
  20. package/lib/steps/upgrade/twopointoh/backend/extension-models.d.ts +30 -0
  21. package/lib/steps/upgrade/twopointoh/backend/extension-models.js +34 -0
  22. package/lib/steps/upgrade/twopointoh/backend/json-api.d.ts +6 -0
  23. package/lib/steps/upgrade/twopointoh/backend/json-api.js +14 -0
  24. package/lib/steps/upgrade/twopointoh/backend/misc.d.ts +6 -0
  25. package/lib/steps/upgrade/twopointoh/backend/misc.js +19 -7
  26. package/lib/steps/upgrade/twopointoh/backend/model-factories.d.ts +18 -0
  27. package/lib/steps/upgrade/twopointoh/backend/model-factories.js +73 -0
  28. package/lib/steps/upgrade/twopointoh/backend/phpunit.d.ts +2 -0
  29. package/lib/steps/upgrade/twopointoh/backend/phpunit.js +16 -8
  30. package/lib/steps/upgrade/twopointoh/backend/search.d.ts +9 -0
  31. package/lib/steps/upgrade/twopointoh/backend/search.js +28 -10
  32. package/lib/steps/upgrade/twopointoh/base.d.ts +38 -0
  33. package/lib/steps/upgrade/twopointoh/base.js +73 -12
  34. package/lib/steps/upgrade/twopointoh/dependencies.d.ts +55 -0
  35. package/lib/steps/upgrade/twopointoh/dependencies.js +317 -20
  36. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.d.ts +86 -0
  37. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.js +842 -0
  38. package/lib/steps/upgrade/twopointoh/frontend/code-splitting/usage-of-lazy-modules.js +110 -18
  39. package/lib/steps/upgrade/twopointoh/frontend/export-registry/import-ext.js +13 -16
  40. package/lib/steps/upgrade/twopointoh/frontend/misc.d.ts +23 -0
  41. package/lib/steps/upgrade/twopointoh/frontend/misc.js +147 -8
  42. package/lib/steps/upgrade/twopointoh/infra.d.ts +16 -1
  43. package/lib/steps/upgrade/twopointoh/infra.js +87 -5
  44. package/lib/steps/upgrade/twopointoh/step-failures.d.ts +8 -0
  45. package/lib/steps/upgrade/twopointoh/step-failures.js +6 -0
  46. package/lib/steps/upgrade/twopointoh/todos.d.ts +22 -0
  47. package/lib/steps/upgrade/twopointoh/todos.js +57 -0
  48. package/lib/steps/upgrade/twopointoh/typescript-5-9.d.ts +15 -0
  49. package/lib/steps/upgrade/twopointoh/typescript-5-9.js +250 -0
  50. package/lib/utils/ast.js +3 -2
  51. package/lib/utils/babel-ts.d.ts +9 -0
  52. package/lib/utils/babel-ts.js +18 -0
  53. package/lib/utils/composer.d.ts +5 -0
  54. package/lib/utils/composer.js +10 -1
  55. package/lib/utils/terminal.d.ts +13 -0
  56. package/lib/utils/terminal.js +26 -0
  57. package/oclif.manifest.json +1 -1
  58. package/package.json +1 -1
  59. package/php-subsystem/index.php +12 -0
  60. package/php-subsystem/src/NodeUtil.php +19 -9
  61. package/php-subsystem/src/NodeVisitors/AddExtender.php +49 -1
  62. package/php-subsystem/src/Upgrade/TwoPointOh/JsonApi.php +2 -20
  63. package/php-subsystem/src/Upgrade/TwoPointOh/Misc.php +13 -17
  64. package/php-subsystem/src/Upgrade/TwoPointOh/ModelFactories.php +943 -0
  65. package/php-subsystem/src/Upgrade/TwoPointOh/PhpUnit.php +476 -71
  66. package/php-subsystem/src/Upgrade/TwoPointOh/Search.php +97 -13
@@ -0,0 +1,842 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const fs_1 = tslib_1.__importDefault(require("fs"));
5
+ const path_1 = tslib_1.__importDefault(require("path"));
6
+ const globby_1 = tslib_1.__importDefault(require("globby"));
7
+ const chalk_1 = tslib_1.__importDefault(require("chalk"));
8
+ const traverse_1 = tslib_1.__importDefault(require("@babel/traverse"));
9
+ const t = tslib_1.__importStar(require("@babel/types"));
10
+ const base_1 = require("../base");
11
+ const ast_1 = require("../../../../utils/ast");
12
+ const babel_ts_1 = require("../../../../utils/babel-ts");
13
+ const composer_1 = require("../../../../utils/composer");
14
+ const TODO = '@TODO: register this with Extend.Admin in admin/extend instead';
15
+ const DOCS = 'https://docs.flarum.org/2.x/extend/admin';
16
+ /**
17
+ * Flarum 2.0 registers an extension's settings, permissions and page with the
18
+ * Admin extender, in admin/extend, which is declarative and runs when the admin
19
+ * mounts, rather than through the registry in an initializer.
20
+ *
21
+ * Registrations move only when they would do the same thing from there: each
22
+ * uses nothing the initializer declares, and nothing else registers with the
23
+ * registry as the admin boots, since registering later than that would change
24
+ * their order. Otherwise, all of the extension's registrations stay, with a
25
+ * @TODO saying why.
26
+ */
27
+ class AdminExtender extends base_1.BaseUpgradeStep {
28
+ constructor() {
29
+ super(...arguments);
30
+ this.type = 'Register admin settings, permissions and pages with Extend.Admin';
31
+ this.moved = [];
32
+ this.left = [];
33
+ }
34
+ replacements(file) {
35
+ return /^js\/src\/admin\/index\.(js|jsx|ts|tsx)$/.test(file) ? [(file, code) => this.upgrade(file, code)] : [];
36
+ }
37
+ targets() {
38
+ return ['js/src/admin/index.*'];
39
+ }
40
+ gitCommit() {
41
+ return {
42
+ message: 'chore(2.0): register admin settings, permissions and pages with Extend.Admin',
43
+ description: 'Flarum 2.0 registers them with the Admin extender in admin/extend.',
44
+ };
45
+ }
46
+ pauseMessage() {
47
+ return [
48
+ this.moved.length > 0 && `The admin settings, permissions and pages are registered with Extend.Admin now, in ${this.moved.join(', ')}.`,
49
+ this.left.length > 0 && `Some in ${this.left.join(', ')} were left where they are; look for the @TODO comments.`,
50
+ chalk_1.default.underline(DOCS),
51
+ ]
52
+ .filter(Boolean)
53
+ .join('\n ');
54
+ }
55
+ async upgrade(file, code) {
56
+ const ast = (0, babel_ts_1.parse)(code);
57
+ const program = programOf(ast);
58
+ const registrations = this.registrations(program);
59
+ if (registrations.length === 0)
60
+ return null;
61
+ let blocked = this.otherRegistryUse(program, registrations, file);
62
+ const target = blocked ? null : this.target(program, file);
63
+ if (target && 'reason' in target) {
64
+ blocked = target.reason;
65
+ }
66
+ if (blocked || registrations.some((registration) => registration.reason)) {
67
+ return this.leave(file, code, registrations, blocked);
68
+ }
69
+ return this.move(file, code, program, registrations, target);
70
+ }
71
+ /**
72
+ * The registration chains written as statements of an admin initializer,
73
+ * `app.initializers.add(name, () => { ... })`.
74
+ */
75
+ registrations(program) {
76
+ const registrations = [];
77
+ for (const statement of program.get('body')) {
78
+ const initializer = initializerOf(statement);
79
+ if (!initializer)
80
+ continue;
81
+ for (const inner of initializer.get('body').get('body')) {
82
+ if (!inner.isExpressionStatement())
83
+ continue;
84
+ const chain = chainOf(inner.node.expression);
85
+ if (chain) {
86
+ registrations.push(this.registration(inner, initializer, chain));
87
+ }
88
+ }
89
+ }
90
+ return registrations;
91
+ }
92
+ registration(statement, initializer, chain) {
93
+ const registration = {
94
+ statement,
95
+ calls: chain.calls,
96
+ context: null,
97
+ reason: null,
98
+ imports: new Map(),
99
+ constants: new Set(),
100
+ usesGlobalApp: false,
101
+ };
102
+ const because = (reason) => {
103
+ var _a;
104
+ (_a = registration.reason) !== null && _a !== void 0 ? _a : (registration.reason = reason);
105
+ };
106
+ const forArg = chain.root.arguments[0];
107
+ if (chain.root.arguments.length === 1 && t.isStringLiteral(forArg)) {
108
+ registration.context = forArg.value;
109
+ }
110
+ else if (chain.root.arguments.length === 1 && t.isIdentifier(forArg)) {
111
+ const binding = statement.scope.getBinding(forArg.name);
112
+ const constants = binding && staticConstants(binding);
113
+ const init = (binding === null || binding === void 0 ? void 0 : binding.path.isVariableDeclarator()) ? binding.path.node.init : null;
114
+ if (constants && t.isStringLiteral(init)) {
115
+ registration.context = init.value;
116
+ }
117
+ }
118
+ if (registration.context === null) {
119
+ because('for() is not given a string the upgrade can read');
120
+ }
121
+ for (const call of chain.calls) {
122
+ const method = call.callee.property;
123
+ const arity = ARITY[method.name];
124
+ if (!arity) {
125
+ because(`it calls ${method.name}(), which Extend.Admin has no equivalent of`);
126
+ }
127
+ else if (call.arguments.length < arity[0] || call.arguments.length > arity[1] || call.arguments.some((arg) => !t.isExpression(arg))) {
128
+ because(`its ${method.name}() is not given the arguments the upgrade expects`);
129
+ }
130
+ }
131
+ const inRoot = (p) => Boolean(p.findParent((parent) => parent.node === chain.root));
132
+ const appParam = initializer.node.params[0];
133
+ // Babel's scope has no bindings for these.
134
+ const types = typeDeclarations(statement.scope.getProgramParent().path.node);
135
+ const reference = (p) => {
136
+ if (!p.isReferencedIdentifier() || inRoot(p))
137
+ return;
138
+ const name = p.node.name;
139
+ const binding = p.scope.getBinding(name);
140
+ if (!binding) {
141
+ if (types.has(name)) {
142
+ because(`it uses ${name}, which this file declares`);
143
+ return;
144
+ }
145
+ if (name === 'arguments' &&
146
+ !p.findParent((parent) => parent.isFunction() && !parent.isArrowFunctionExpression() && parent.isDescendant(statement))) {
147
+ because("it uses arguments, which are the initializer's");
148
+ }
149
+ if (name === 'app')
150
+ registration.usesGlobalApp = true;
151
+ return;
152
+ }
153
+ if (binding.path.isDescendant(statement))
154
+ return;
155
+ if (binding.kind === 'param' && binding.path.node === appParam && name === 'app') {
156
+ registration.usesGlobalApp = true;
157
+ return;
158
+ }
159
+ if (isImport(binding)) {
160
+ registration.imports.set(name, binding);
161
+ return;
162
+ }
163
+ const constants = binding.scope.path.isProgram() ? staticConstants(binding) : null;
164
+ if (constants) {
165
+ constants.forEach((constant) => registration.constants.add(constant));
166
+ return;
167
+ }
168
+ because(`it uses ${name}, which this ${binding.path.isDescendant(initializer) ? 'initializer' : 'file'} declares`);
169
+ };
170
+ statement.traverse({
171
+ ThisExpression(p) {
172
+ if (!p.findParent((parent) => parent.isFunction() && !parent.isArrowFunctionExpression() && parent.isDescendant(statement))) {
173
+ because("it uses this, which is the initializer's");
174
+ }
175
+ },
176
+ Identifier: (p) => reference(p),
177
+ JSXIdentifier: (p) => reference(p),
178
+ });
179
+ // Imports the constants' values use go along with them.
180
+ for (const constant of registration.constants) {
181
+ constant.path.traverse({
182
+ Identifier(p) {
183
+ if (!p.isReferencedIdentifier())
184
+ return;
185
+ const binding = p.scope.getBinding(p.node.name);
186
+ if (binding && isImport(binding))
187
+ registration.imports.set(binding.identifier.name, binding);
188
+ },
189
+ });
190
+ }
191
+ return registration;
192
+ }
193
+ /**
194
+ * Why nothing can move because of other code using the registry: in another
195
+ * admin file, or in this one other than in a registration chain or in a
196
+ * function that runs later, through extend() or override().
197
+ */
198
+ otherRegistryUse(program, registrations, file) {
199
+ const others = globby_1.default
200
+ .sync('js/src/admin/**/*.{js,jsx,ts,tsx}', { cwd: this.root })
201
+ .filter((other) => other !== file)
202
+ .sort()
203
+ .find((other) => REGISTRY.test(fs_1.default.readFileSync(path_1.default.join(this.root, other), 'utf8')));
204
+ if (others)
205
+ return `${others} uses the registry too`;
206
+ const statements = new Set(registrations.map((registration) => registration.statement.node));
207
+ let reason = null;
208
+ program.traverse({
209
+ MemberExpression(p) {
210
+ if (reason || !isRegistry(p.node))
211
+ return;
212
+ const inRegistration = p.findParent((parent) => statements.has(parent.node));
213
+ const runsLater = p.findParent((parent) => parent.isFunction() && isLaterCallback(parent));
214
+ if (!inRegistration && !runsLater) {
215
+ reason = 'other code in this file uses the registry as it runs';
216
+ }
217
+ },
218
+ });
219
+ return reason;
220
+ }
221
+ /**
222
+ * Where the extender goes: the admin/extend the index exports, or a new one.
223
+ */
224
+ target(program, file) {
225
+ const lang = path_1.default.extname(file).slice(1);
226
+ const dir = path_1.default.posix.dirname(file);
227
+ const existing = ['ts', 'tsx', 'js', 'jsx']
228
+ .map((ext) => `${dir}/extend.${ext}`)
229
+ .find((candidate) => fs_1.default.existsSync(path_1.default.join(this.root, candidate)));
230
+ const exported = program.node.body.filter((node) => {
231
+ var _a;
232
+ return (t.isExportNamedDeclaration(node) &&
233
+ (node.specifiers.some((specifier) => t.isExportSpecifier(specifier) && exportedName(specifier) === 'extend') ||
234
+ (t.isVariableDeclaration(node.declaration) && node.declaration.declarations.some((d) => t.isIdentifier(d.id, { name: 'extend' }))) ||
235
+ (t.isFunctionDeclaration(node.declaration) && ((_a = node.declaration.id) === null || _a === void 0 ? void 0 : _a.name) === 'extend'))) ||
236
+ t.isExportAllDeclaration(node);
237
+ });
238
+ const reexport = exported.find((node) => t.isExportNamedDeclaration(node) && node.source !== null && node.source !== undefined);
239
+ if (exported.some((node) => !t.isExportAllDeclaration(node) && node !== reexport)) {
240
+ return { reason: 'the index exports its extenders itself' };
241
+ }
242
+ if (!reexport) {
243
+ if (existing) {
244
+ return { reason: `${existing} exists, but the index does not export it` };
245
+ }
246
+ return { file: `${dir}/extend.${lang}`, existing: null, common: null };
247
+ }
248
+ const source = reexport.source.value;
249
+ const specifier = reexport.specifiers.find((s) => t.isExportSpecifier(s) && exportedName(s) === 'extend');
250
+ if (!t.isIdentifier(specifier.local, { name: 'default' })) {
251
+ return { reason: 'the index exports something other than a default export as extend' };
252
+ }
253
+ if (source.startsWith('./')) {
254
+ const resolved = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx']
255
+ .map((suffix) => path_1.default.posix.join(dir, source + suffix))
256
+ .find((candidate) => fs_1.default.existsSync(path_1.default.join(this.root, candidate)) && fs_1.default.statSync(path_1.default.join(this.root, candidate)).isFile());
257
+ if (!resolved) {
258
+ return { reason: `the index exports extend from ${source}, which the upgrade could not find` };
259
+ }
260
+ return { file: resolved, existing: fs_1.default.readFileSync(path_1.default.join(this.root, resolved), 'utf8'), common: null };
261
+ }
262
+ if (existing) {
263
+ return { reason: `${existing} exists, but the index exports ${source} instead` };
264
+ }
265
+ if (reexport.specifiers.length > 1) {
266
+ return { reason: `the index exports more than extend from ${source}` };
267
+ }
268
+ return { file: `${dir}/extend.${lang}`, existing: null, common: { source, declaration: reexport } };
269
+ }
270
+ leave(file, code, registrations, blocked) {
271
+ var _a, _b, _c;
272
+ const edits = [];
273
+ for (const registration of registrations) {
274
+ const node = registration.statement.node;
275
+ if ((_a = node.leadingComments) === null || _a === void 0 ? void 0 : _a.some((comment) => comment.value.includes(TODO)))
276
+ continue;
277
+ const reason = (_c = (_b = registration.reason) !== null && _b !== void 0 ? _b : blocked) !== null && _c !== void 0 ? _c : 'another registration in this file cannot move, and moving only some would change their order';
278
+ const lineStart = code.lastIndexOf('\n', node.start - 1) + 1;
279
+ const indent = code.slice(lineStart, node.start);
280
+ edits.push({ start: lineStart, end: lineStart, text: `${indent}// ${TODO} (${DOCS}). The upgrade left it here because ${reason}.\n` });
281
+ }
282
+ if (edits.length === 0)
283
+ return null;
284
+ this.left.push(file);
285
+ return { updated: applyEdits(code, edits) };
286
+ }
287
+ async move(file, code, program, registrations, target) {
288
+ const id = this.extensionId();
289
+ const existing = target.existing === null ? null : parseExtend(target.existing);
290
+ if (existing && 'reason' in existing) {
291
+ return this.leave(file, code, registrations, `${target.file} ${existing.reason}`);
292
+ }
293
+ // What the extend file needs from the index.
294
+ const imports = new Map();
295
+ const constants = new Set();
296
+ let usesGlobalApp = false;
297
+ for (const registration of registrations) {
298
+ registration.imports.forEach((binding, name) => imports.set(name, binding));
299
+ registration.constants.forEach((constant) => constants.add(constant));
300
+ usesGlobalApp || (usesGlobalApp = registration.usesGlobalApp && !registration.imports.has('app'));
301
+ }
302
+ const needed = [defaultImport('Extend', 'flarum/common/extenders')];
303
+ if (target.common) {
304
+ needed.push(defaultImport('commonExtend', target.common.source));
305
+ }
306
+ if (usesGlobalApp && !imports.has('app')) {
307
+ needed.push(defaultImport('app', 'flarum/admin/app'));
308
+ }
309
+ needed.push(...importsNeeded(imports));
310
+ const constantDeclarations = [...constants].sort((a, b) => a.path.node.start - b.path.node.start);
311
+ for (const constant of constantDeclarations) {
312
+ const declarator = constant.path.node;
313
+ needed.push({ name: constant.identifier.name, text: `const ${slice(code, declarator)};`, kind: 'const' });
314
+ }
315
+ // Each name once, and not one the extend file uses for something else.
316
+ const missing = [];
317
+ for (const need of needed) {
318
+ const twin = needed.find((other) => other !== need && other.name === need.name);
319
+ if (twin && !sameBinding(twin, need)) {
320
+ return this.leave(file, code, registrations, `admin/extend would need two different things named ${need.name}`);
321
+ }
322
+ const has = existing === null || existing === void 0 ? void 0 : existing.bindings.get(need.name);
323
+ if (has && !sameBinding(has, need)) {
324
+ return this.leave(file, code, registrations, `${target.file} already uses the name ${need.name}`);
325
+ }
326
+ if (!has && !missing.some((other) => other.name === need.name)) {
327
+ missing.push(need);
328
+ }
329
+ }
330
+ const importLines = printImports(missing, code);
331
+ const constantLines = missing.filter((need) => need.kind === 'const').map((need) => need.text);
332
+ // The extenders, one for each extension named, in the order they appear.
333
+ const contexts = [];
334
+ for (const registration of registrations) {
335
+ if (!contexts.includes(registration.context))
336
+ contexts.push(registration.context);
337
+ }
338
+ const extenders = contexts.map((context) => {
339
+ const args = context === id ? '' : `'${context.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
340
+ return (`new Extend.Admin(${args})` +
341
+ registrations
342
+ .filter((registration) => registration.context === context)
343
+ .map((registration) => methods(registration, code))
344
+ .join(''));
345
+ });
346
+ const extendCode = existing
347
+ ? appendTo(target.existing, existing, importLines, constantLines, extenders)
348
+ : newExtend(importLines, constantLines, extenders, Boolean(target.common));
349
+ const index = indexEdits(code, program, registrations, imports, constants, target);
350
+ this.moved.push(target.file);
351
+ return {
352
+ updated: index,
353
+ newFiles: [{ path: target.file, code: await (0, ast_1.formatCode)(extendCode) }],
354
+ };
355
+ }
356
+ /** The extension's ID, as core makes it from the package name. */
357
+ extensionId() {
358
+ try {
359
+ return (0, composer_1.extensionId)(JSON.parse(fs_1.default.readFileSync(path_1.default.join(this.root, 'composer.json'), 'utf8')).name);
360
+ }
361
+ catch (_a) {
362
+ return null;
363
+ }
364
+ }
365
+ }
366
+ exports.default = AdminExtender;
367
+ /** The arguments each registry method takes, at least and at most. */
368
+ const ARITY = {
369
+ registerSetting: [1, 2],
370
+ registerPermission: [2, 3],
371
+ registerPage: [1, 1],
372
+ };
373
+ const REGISTRY = /\bapp\.(extensionData|registry)\b/;
374
+ function programOf(ast) {
375
+ let program = null;
376
+ (0, traverse_1.default)(ast, {
377
+ Program(p) {
378
+ program = p;
379
+ p.stop();
380
+ },
381
+ });
382
+ return program;
383
+ }
384
+ const slice = (code, node) => code.slice(node.start, node.end);
385
+ const isApp = (node) => t.isIdentifier(node, { name: 'app' });
386
+ function isRegistry(node) {
387
+ return isApp(node.object) && !node.computed && t.isIdentifier(node.property) && ['extensionData', 'registry'].includes(node.property.name);
388
+ }
389
+ /** The callback of `app.initializers.add(name, callback)`, when it has a body. */
390
+ function initializerOf(statement) {
391
+ if (!statement.isExpressionStatement())
392
+ return null;
393
+ const call = statement.get('expression');
394
+ if (!call.isCallExpression())
395
+ return null;
396
+ const callee = call.node.callee;
397
+ if (!t.isMemberExpression(callee) ||
398
+ !t.isIdentifier(callee.property, { name: 'add' }) ||
399
+ !t.isMemberExpression(callee.object) ||
400
+ !isApp(callee.object.object) ||
401
+ !t.isIdentifier(callee.object.property, { name: 'initializers' })) {
402
+ return null;
403
+ }
404
+ const callback = call.get('arguments')[1];
405
+ if (!callback || !(callback.isArrowFunctionExpression() || callback.isFunctionExpression()) || !t.isBlockStatement(callback.node.body)) {
406
+ return null;
407
+ }
408
+ return callback;
409
+ }
410
+ /** `app.extensionData.for(...).registerX(...)...`, or app.registry's. */
411
+ function chainOf(expression) {
412
+ const calls = [];
413
+ let node = expression;
414
+ while (t.isCallExpression(node) && t.isMemberExpression(node.callee) && !node.callee.computed && t.isIdentifier(node.callee.property)) {
415
+ const object = node.callee.object;
416
+ if (node.callee.property.name === 'for' && t.isMemberExpression(object) && isRegistry(object)) {
417
+ return calls.length > 0 ? { root: node, calls: calls.reverse() } : null;
418
+ }
419
+ calls.push(node);
420
+ node = object;
421
+ }
422
+ return null;
423
+ }
424
+ /** The names of the types, interfaces, enums and namespaces a file declares. */
425
+ function typeDeclarations(program) {
426
+ const names = new Set();
427
+ for (const node of program.body) {
428
+ const declaration = t.isExportNamedDeclaration(node) ? node.declaration : node;
429
+ if ((t.isTSInterfaceDeclaration(declaration) ||
430
+ t.isTSTypeAliasDeclaration(declaration) ||
431
+ t.isTSEnumDeclaration(declaration) ||
432
+ t.isTSModuleDeclaration(declaration)) &&
433
+ t.isIdentifier(declaration.id)) {
434
+ names.add(declaration.id.name);
435
+ }
436
+ }
437
+ return names;
438
+ }
439
+ function isImport(binding) {
440
+ return binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier() || binding.path.isImportNamespaceSpecifier();
441
+ }
442
+ /**
443
+ * A module-level constant whose value is fixed, with the constants its value
444
+ * uses: those can be copied as they are.
445
+ */
446
+ function staticConstants(binding, seen = new Set()) {
447
+ if (seen.has(binding))
448
+ return seen;
449
+ if (binding.kind !== 'const' || !binding.path.isVariableDeclarator() || !binding.scope.path.isProgram() || !binding.path.node.init) {
450
+ return null;
451
+ }
452
+ seen.add(binding);
453
+ const init = binding.path.get('init');
454
+ return isStatic(init, seen) ? seen : null;
455
+ }
456
+ function isStatic(p, seen) {
457
+ const node = p.node;
458
+ if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node) || t.isNullLiteral(node))
459
+ return true;
460
+ if (t.isTemplateLiteral(node))
461
+ return p.get('expressions').every((expression) => isStatic(expression, seen));
462
+ if (t.isBinaryExpression(node))
463
+ return isStatic(p.get('left'), seen) && isStatic(p.get('right'), seen);
464
+ if (t.isUnaryExpression(node))
465
+ return isStatic(p.get('argument'), seen);
466
+ if (t.isTSAsExpression(node) || t.isParenthesizedExpression(node))
467
+ return isStatic(p.get('expression'), seen);
468
+ if (t.isIdentifier(node)) {
469
+ const binding = p.scope.getBinding(node.name);
470
+ return Boolean(binding && staticConstants(binding, seen));
471
+ }
472
+ return false;
473
+ }
474
+ /** A function given to extend() or override(), which runs when the extended method does. */
475
+ function isLaterCallback(fn) {
476
+ const call = fn.parentPath;
477
+ if (!(call === null || call === void 0 ? void 0 : call.isCallExpression()) || fn.listKey !== 'arguments' || !t.isIdentifier(call.node.callee))
478
+ return false;
479
+ const binding = call.scope.getBinding(call.node.callee.name);
480
+ if (!binding || !binding.path.isImportSpecifier())
481
+ return false;
482
+ const declaration = binding.path.parent;
483
+ const imported = binding.path.node.imported;
484
+ const importedName = t.isIdentifier(imported) ? imported.name : imported.value;
485
+ return /(^|\/)extend$/.test(declaration.source.value) && ['extend', 'override'].includes(importedName);
486
+ }
487
+ const exportedName = (specifier) => (t.isIdentifier(specifier.exported) ? specifier.exported.name : specifier.exported.value);
488
+ /** The extender methods for a registration's calls, with the comments among them. */
489
+ function methods(registration, code) {
490
+ var _a;
491
+ const node = registration.statement.node;
492
+ const parent = registration.statement.parentPath.node;
493
+ const previous = parent.body[parent.body.indexOf(node) - 1];
494
+ // Comments above the statement, but not one ending the line before it.
495
+ const leading = ((_a = node.leadingComments) !== null && _a !== void 0 ? _a : []).filter((comment) => !previous || comment.loc.start.line > previous.loc.end.line);
496
+ const argRanges = registration.calls.flatMap((call) => call.arguments.map((arg) => [arg.start, arg.end]));
497
+ const inArgs = (comment) => argRanges.some(([start, end]) => comment.start >= start && comment.end <= end);
498
+ let text = '';
499
+ registration.calls.forEach((call, i) => {
500
+ const start = i === 0 ? node.start : registration.calls[i - 1].end;
501
+ const comments = [...(i === 0 ? leading : []), ...commentsIn(start, call.end, node).filter((comment) => !inArgs(comment))];
502
+ text += comments.map((comment) => '\n' + printComment(comment)).join('') + '\n';
503
+ text += method(call, code);
504
+ });
505
+ // Ones after the last call, before the semicolon.
506
+ const last = registration.calls[registration.calls.length - 1];
507
+ for (const comment of commentsIn(last.end, node.end, node)) {
508
+ text += ' ' + printComment(comment) + (comment.type === 'CommentLine' ? '\n' : '');
509
+ }
510
+ return text;
511
+ }
512
+ const printComment = (comment) => (comment.type === 'CommentLine' ? `//${comment.value}` : `/*${comment.value}*/`);
513
+ /** The comments the statement's own source has between two offsets. */
514
+ function commentsIn(start, end, statement) {
515
+ const all = [];
516
+ t.traverseFast(statement, (node) => {
517
+ var _a, _b, _c;
518
+ for (const comment of [...((_a = node.leadingComments) !== null && _a !== void 0 ? _a : []), ...((_b = node.innerComments) !== null && _b !== void 0 ? _b : []), ...((_c = node.trailingComments) !== null && _c !== void 0 ? _c : [])]) {
519
+ if (comment.start >= start && comment.end <= end && !all.some((c) => c.start === comment.start))
520
+ all.push(comment);
521
+ }
522
+ });
523
+ return all.sort((a, b) => a.start - b.start);
524
+ }
525
+ function method(call, code) {
526
+ const name = call.callee.property.name;
527
+ const [first, ...rest] = call.arguments.map((arg) => slice(code, arg));
528
+ const more = rest.map((arg) => `, ${arg}`).join('');
529
+ switch (name) {
530
+ case 'registerSetting':
531
+ return t.isFunction(call.arguments[0]) ? `.customSetting(${first}${more})` : `.setting(() => (${first})${more})`;
532
+ case 'registerPermission':
533
+ return `.permission(() => (${first})${more})`;
534
+ default:
535
+ return `.page(${first})`;
536
+ }
537
+ }
538
+ /** What the extend file needs from the index's imports, in the index's order. */
539
+ function importsNeeded(imports) {
540
+ return [...imports.values()]
541
+ .sort((a, b) => a.path.node.start - b.path.node.start)
542
+ .map((binding) => {
543
+ const declaration = binding.path.parent;
544
+ const specifier = binding.path.node;
545
+ return {
546
+ name: specifier.local.name,
547
+ kind: 'import',
548
+ source: declaration.source.value,
549
+ imported: importedOf(specifier),
550
+ typeOnly: isTypeOnly(declaration, specifier),
551
+ declaration,
552
+ specifier,
553
+ };
554
+ });
555
+ }
556
+ function defaultImport(name, source) {
557
+ return { name, kind: 'import', source, imported: 'default', typeOnly: false, text: `import ${name} from '${source}';` };
558
+ }
559
+ const isTypeOnly = (declaration, specifier) => declaration.importKind === 'type' || (t.isImportSpecifier(specifier) && specifier.importKind === 'type');
560
+ /** The import lines for what is missing: the index's imports keep their declarations, with only the names needed. */
561
+ function printImports(missing, code) {
562
+ const order = [];
563
+ const specifiers = new Map();
564
+ for (const need of missing) {
565
+ if (need.kind !== 'import')
566
+ continue;
567
+ if (need.text) {
568
+ order.push(need.text);
569
+ }
570
+ else if (specifiers.has(need.declaration)) {
571
+ specifiers.get(need.declaration).push(need.specifier);
572
+ }
573
+ else {
574
+ specifiers.set(need.declaration, [need.specifier]);
575
+ order.push(need.declaration);
576
+ }
577
+ }
578
+ return order.map((line) => (typeof line === 'string' ? line : printImport(line, specifiers.get(line), code)));
579
+ }
580
+ function importedOf(specifier) {
581
+ if (t.isImportDefaultSpecifier(specifier))
582
+ return 'default';
583
+ if (t.isImportNamespaceSpecifier(specifier))
584
+ return '*';
585
+ return t.isIdentifier(specifier.imported) ? specifier.imported.name : specifier.imported.value;
586
+ }
587
+ function printImport(declaration, specifiers, code) {
588
+ const parts = [];
589
+ const named = [];
590
+ for (const specifier of specifiers) {
591
+ if (t.isImportDefaultSpecifier(specifier)) {
592
+ parts.unshift(specifier.local.name);
593
+ }
594
+ else if (t.isImportNamespaceSpecifier(specifier)) {
595
+ parts.push(`* as ${specifier.local.name}`);
596
+ }
597
+ else {
598
+ const imported = importedOf(specifier);
599
+ const type = specifier.importKind === 'type' ? 'type ' : '';
600
+ const name = t.isIdentifier(specifier.imported) ? imported : `'${imported}'`;
601
+ named.push(`${type}${name}${imported === specifier.local.name ? '' : ` as ${specifier.local.name}`}`);
602
+ }
603
+ }
604
+ if (named.length > 0)
605
+ parts.push(`{ ${named.join(', ')} }`);
606
+ const type = declaration.importKind === 'type' ? 'type ' : '';
607
+ return `import ${type}${parts.join(', ')} from ${slice(code, declaration.source)};`;
608
+ }
609
+ /** The extend file the extension has: its exported array, and the names it binds. */
610
+ function parseExtend(code) {
611
+ let ast;
612
+ try {
613
+ ast = (0, babel_ts_1.parse)(code);
614
+ }
615
+ catch (_a) {
616
+ return { reason: 'could not be parsed' };
617
+ }
618
+ const exported = ast.program.body.find((node) => t.isExportDefaultDeclaration(node));
619
+ if (!exported || !t.isArrayExpression(exported.declaration)) {
620
+ return { reason: 'does not export an array the upgrade can add to' };
621
+ }
622
+ const bindings = new Map();
623
+ let lastImportEnd = null;
624
+ for (const node of ast.program.body) {
625
+ if (t.isImportDeclaration(node)) {
626
+ lastImportEnd = node.end;
627
+ for (const specifier of node.specifiers) {
628
+ bindings.set(specifier.local.name, {
629
+ name: specifier.local.name,
630
+ kind: 'import',
631
+ source: node.source.value,
632
+ imported: importedOf(specifier),
633
+ typeOnly: isTypeOnly(node, specifier),
634
+ });
635
+ }
636
+ continue;
637
+ }
638
+ const declaration = t.isExportNamedDeclaration(node) ? node.declaration : node;
639
+ if (t.isVariableDeclaration(declaration)) {
640
+ for (const declarator of declaration.declarations) {
641
+ if (t.isIdentifier(declarator.id)) {
642
+ bindings.set(declarator.id.name, { name: declarator.id.name, text: `${declaration.kind} ${slice(code, declarator)};`, kind: 'const' });
643
+ }
644
+ }
645
+ }
646
+ else if ((t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) && declaration.id) {
647
+ bindings.set(declaration.id.name, { name: declaration.id.name, text: slice(code, declaration), kind: 'const' });
648
+ }
649
+ }
650
+ return { ast, array: exported.declaration, bindings, lastImportEnd };
651
+ }
652
+ /** Whether what the file has will do for what is needed: a type-only import will not do for a value. */
653
+ function sameBinding(has, need) {
654
+ if (has.kind === 'import' && need.kind === 'import') {
655
+ return has.source === need.source && has.imported === need.imported && (need.typeOnly || !has.typeOnly);
656
+ }
657
+ return has.kind === need.kind && has.text === need.text;
658
+ }
659
+ function newExtend(imports, constants, extenders, common) {
660
+ const elements = [...(common ? ['...commonExtend'] : []), ...extenders];
661
+ return [imports.join('\n'), constants.join('\n'), `export default [\n${elements.join(',\n\n')},\n];\n`].filter(Boolean).join('\n\n');
662
+ }
663
+ function appendTo(code, existing, imports, constants, extenders) {
664
+ var _a;
665
+ const edits = [];
666
+ const head = [imports.join('\n'), constants.length > 0 ? '\n' + constants.join('\n') : ''].filter(Boolean).join('\n');
667
+ if (head) {
668
+ const at = (_a = existing.lastImportEnd) !== null && _a !== void 0 ? _a : 0;
669
+ edits.push({ start: at, end: at, text: existing.lastImportEnd === null ? head + '\n\n' : '\n' + head });
670
+ }
671
+ // After what the array has, comments included, with a blank line after an element.
672
+ const elements = existing.array.elements;
673
+ const last = elements[elements.length - 1];
674
+ const open = last ? last.end : existing.array.start + 1;
675
+ const close = existing.array.end - 1;
676
+ const between = code.slice(open, close).replace(/\s+$/, '');
677
+ const comma = last && !between.replace(/\/\/[^\n]*|\/\*[\S\s]*?\*\//g, '').includes(',') ? ',' : '';
678
+ const gap = last && !/(\/\/[^\n]*|\*\/)$/.test(between) ? '\n\n' : '\n';
679
+ edits.push({ start: open, end: close, text: comma + between + gap + extenders.join(',\n\n') + ',\n' });
680
+ return applyEdits(code, edits);
681
+ }
682
+ /** The index without the moved registrations and what only they used, exporting extend. */
683
+ function indexEdits(code, program, registrations, imports, constants, target) {
684
+ var _a;
685
+ const edits = [];
686
+ const moved = new Set(registrations.map((registration) => registration.statement.node));
687
+ const emptied = emptiedInitializers(registrations, moved);
688
+ // An initializer left with nothing to do goes as a whole. 2.0 checks
689
+ // whether an extension is enabled in flarum.extensions, not by its
690
+ // initializer, so nothing needs it to stay.
691
+ // The app it added itself to may now go unused too.
692
+ const candidates = new Map(imports);
693
+ for (const initializer of emptied) {
694
+ moved.add(initializer.node);
695
+ edits.push(removal(code, initializer));
696
+ const app = initializer.get('expression.callee.object.object');
697
+ const binding = app.isIdentifier() ? app.scope.getBinding(app.node.name) : undefined;
698
+ if (binding && isImport(binding))
699
+ candidates.set(binding.identifier.name, binding);
700
+ }
701
+ for (const registration of registrations) {
702
+ if (!emptied.some((initializer) => registration.statement.isDescendant(initializer))) {
703
+ edits.push(removal(code, registration.statement));
704
+ }
705
+ }
706
+ // A constant only the moved code, or another constant going, used goes too.
707
+ const going = new Set();
708
+ let changed = true;
709
+ while (changed) {
710
+ changed = false;
711
+ for (const constant of constants) {
712
+ if (going.has(constant) || isExported(constant))
713
+ continue;
714
+ const used = constant.referencePaths.some((reference) => !reference.findParent((parent) => moved.has(parent.node) || [...going].some((other) => other.path.node === parent.node)));
715
+ if (!used) {
716
+ going.add(constant);
717
+ changed = true;
718
+ }
719
+ }
720
+ }
721
+ // The for() names a constant as well.
722
+ for (const registration of registrations) {
723
+ const forArg = chainRoot(registration).arguments[0];
724
+ if (t.isIdentifier(forArg)) {
725
+ const binding = registration.statement.scope.getBinding(forArg.name);
726
+ if (binding && !isImport(binding) && !isExported(binding) && !going.has(binding)) {
727
+ const used = binding.referencePaths.some((reference) => !reference.findParent((parent) => moved.has(parent.node)));
728
+ if (!used)
729
+ going.add(binding);
730
+ }
731
+ }
732
+ }
733
+ for (const constant of going) {
734
+ const declaration = constant.path.parentPath;
735
+ const remaining = declaration.node.declarations.filter((declarator) => ![...going].some((other) => other.path.node === declarator));
736
+ edits.push(remaining.length === 0
737
+ ? wholeLines(code, declaration.node)
738
+ : {
739
+ start: declaration.node.start,
740
+ end: declaration.node.end,
741
+ text: `${declaration.node.kind} ${remaining.map((d) => slice(code, d)).join(', ')};`,
742
+ });
743
+ }
744
+ // An import only the moved code, or constants going, used.
745
+ const outside = (reference) => !reference.findParent((parent) => moved.has(parent.node) || [...going].some((g) => g.path.parentPath.node === parent.node));
746
+ const byDeclaration = new Map();
747
+ for (const binding of candidates.values()) {
748
+ if (references(program, binding).some(outside))
749
+ continue;
750
+ const declaration = binding.path.parent;
751
+ byDeclaration.set(declaration, [...((_a = byDeclaration.get(declaration)) !== null && _a !== void 0 ? _a : []), binding.path.node]);
752
+ }
753
+ const removedImports = new Set();
754
+ for (const [declaration, unused] of byDeclaration) {
755
+ const remaining = declaration.specifiers.filter((specifier) => !unused.includes(specifier));
756
+ if (remaining.length === 0) {
757
+ removedImports.add(declaration);
758
+ edits.push(wholeLines(code, declaration));
759
+ }
760
+ else {
761
+ edits.push({ start: declaration.start, end: declaration.end, text: printImport(declaration, remaining, code) });
762
+ }
763
+ }
764
+ // Export extend from the new file.
765
+ if (target.common) {
766
+ const source = target.common.declaration.source;
767
+ edits.push({ start: source.start, end: source.end, text: "'./extend'" });
768
+ }
769
+ else if (target.existing === null) {
770
+ const lastImport = [...program.node.body].reverse().find((node) => t.isImportDeclaration(node) && !removedImports.has(node));
771
+ const line = "export { default as extend } from './extend';";
772
+ edits.push(lastImport ? { start: lastImport.end, end: lastImport.end, text: `\n\n${line}` } : { start: 0, end: 0, text: `${line}\n\n` });
773
+ }
774
+ return applyEdits(code, edits);
775
+ }
776
+ const chainRoot = (registration) => registration.calls[0].callee.object;
777
+ function isExported(binding) {
778
+ var _a, _b;
779
+ return Boolean((_b = (_a = binding.path.parentPath) === null || _a === void 0 ? void 0 : _a.parentPath) === null || _b === void 0 ? void 0 : _b.isExportNamedDeclaration());
780
+ }
781
+ /** Every reference to a binding, including in types, which Babel does not count. */
782
+ function references(program, binding) {
783
+ const found = [];
784
+ const visit = (p) => {
785
+ if (p.isReferencedIdentifier() && p.scope.getBinding(p.node.name) === binding)
786
+ found.push(p);
787
+ };
788
+ program.traverse({ Identifier: visit, JSXIdentifier: visit });
789
+ return found;
790
+ }
791
+ /**
792
+ * The `app.initializers.add(...)` statements that only held registrations
793
+ * which are moving, and no comments of their own.
794
+ */
795
+ function emptiedInitializers(registrations, moved) {
796
+ var _a, _b, _c, _d, _e;
797
+ const emptied = [];
798
+ for (const registration of registrations) {
799
+ const block = registration.statement.parentPath;
800
+ const statement = (_b = (_a = block.parentPath) === null || _a === void 0 ? void 0 : _a.parentPath) === null || _b === void 0 ? void 0 : _b.parentPath;
801
+ if (!(statement === null || statement === void 0 ? void 0 : statement.isExpressionStatement()) || emptied.some((other) => other.node === statement.node))
802
+ continue;
803
+ const body = block.node.body;
804
+ const removed = new Set(body.flatMap((node) => { var _a; return (_a = node.leadingComments) !== null && _a !== void 0 ? _a : []; }));
805
+ const kept = [...((_c = block.node.innerComments) !== null && _c !== void 0 ? _c : []), ...((_e = (_d = body.at(-1)) === null || _d === void 0 ? void 0 : _d.trailingComments) !== null && _e !== void 0 ? _e : [])].filter((comment) => !removed.has(comment));
806
+ if (body.every((node) => moved.has(node)) && kept.length === 0) {
807
+ emptied.push(statement);
808
+ }
809
+ }
810
+ return emptied;
811
+ }
812
+ /** The statement's lines, with the comments above it and one ending its line. */
813
+ function removal(code, statement) {
814
+ var _a;
815
+ const node = statement.node;
816
+ const parent = statement.parentPath.node;
817
+ const previous = parent.body[parent.body.indexOf(node) - 1];
818
+ const leading = ((_a = node.leadingComments) !== null && _a !== void 0 ? _a : []).filter((comment) => !previous || comment.loc.start.line > previous.loc.end.line);
819
+ const start = leading.length > 0 ? leading[0].start : node.start;
820
+ return wholeLines(code, { start, end: node.end });
821
+ }
822
+ /** A node's lines, when nothing else is on them; otherwise just the node. */
823
+ function wholeLines(code, node) {
824
+ const lineStart = code.lastIndexOf('\n', node.start - 1) + 1;
825
+ const lineEnd = code.indexOf('\n', node.end);
826
+ const end = lineEnd === -1 ? code.length : lineEnd + 1;
827
+ const before = code.slice(lineStart, node.start);
828
+ const after = code.slice(node.end, end);
829
+ if (before.trim() === '' && after.trim() === '') {
830
+ return { start: lineStart, end, text: '' };
831
+ }
832
+ return { start: node.start, end: node.end, text: '' };
833
+ }
834
+ function applyEdits(code, edits) {
835
+ const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
836
+ for (let i = 1; i < sorted.length; i++) {
837
+ if (sorted[i].end > sorted[i - 1].start && sorted[i].start !== sorted[i].end) {
838
+ throw new Error('The upgrade tried to change the same code twice.');
839
+ }
840
+ }
841
+ return sorted.reduce((result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end), code);
842
+ }