@flarum/cli 3.0.11 → 3.0.13

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 (76) hide show
  1. package/README.md +33 -1
  2. package/bin/libgit2.1.5.dylib +0 -0
  3. package/bin/splitsh-lite-darwin-arm64 +0 -0
  4. package/boilerplate/skeleton/extension/composer.json +4 -0
  5. package/boilerplate/skeleton/extension/js/package.json +5 -5
  6. package/boilerplate/skeleton/extension/js/tsconfig.json +3 -0
  7. package/boilerplate/skeleton/extension/js/tsconfig.test.json +1 -0
  8. package/boilerplate/skeleton/extension/phpstan.neon +0 -1
  9. package/boilerplate/skeleton/extension/tests/phpunit.integration.xml +1 -0
  10. package/boilerplate/skeleton/extension/tests/phpunit.unit.xml +2 -1
  11. package/boilerplate/stubs/backend/advanced-api-resource.php +3 -3
  12. package/boilerplate/stubs/backend/model-factory.php +17 -0
  13. package/boilerplate/stubs/backend/model.php +3 -0
  14. package/lib/base-command.d.ts +21 -3
  15. package/lib/base-command.js +52 -5
  16. package/lib/commands/make/backend/model.js +10 -0
  17. package/lib/commands/monorepo/split.js +1 -1
  18. package/lib/commands/upgrade/2.0.d.ts +43 -0
  19. package/lib/commands/upgrade/2.0.js +128 -2
  20. package/lib/steps/gen-ext-scaffolder.d.ts +5 -0
  21. package/lib/steps/gen-ext-scaffolder.js +17 -2
  22. package/lib/steps/monorepo/split.d.ts +2 -1
  23. package/lib/steps/monorepo/split.js +20 -5
  24. package/lib/steps/stubs/backend/advanced-api-resource.js +1 -1
  25. package/lib/steps/stubs/backend/model-factory.d.ts +24 -0
  26. package/lib/steps/stubs/backend/model-factory.js +33 -0
  27. package/lib/steps/upgrade/twopointoh/backend/email-views.js +22 -30
  28. package/lib/steps/upgrade/twopointoh/backend/extension-models.d.ts +30 -0
  29. package/lib/steps/upgrade/twopointoh/backend/extension-models.js +34 -0
  30. package/lib/steps/upgrade/twopointoh/backend/json-api.d.ts +6 -0
  31. package/lib/steps/upgrade/twopointoh/backend/json-api.js +14 -0
  32. package/lib/steps/upgrade/twopointoh/backend/misc.d.ts +6 -0
  33. package/lib/steps/upgrade/twopointoh/backend/misc.js +19 -7
  34. package/lib/steps/upgrade/twopointoh/backend/model-factories.d.ts +18 -0
  35. package/lib/steps/upgrade/twopointoh/backend/model-factories.js +73 -0
  36. package/lib/steps/upgrade/twopointoh/backend/phpunit-xml.d.ts +8 -0
  37. package/lib/steps/upgrade/twopointoh/backend/phpunit-xml.js +33 -0
  38. package/lib/steps/upgrade/twopointoh/backend/phpunit.d.ts +2 -0
  39. package/lib/steps/upgrade/twopointoh/backend/phpunit.js +34 -21
  40. package/lib/steps/upgrade/twopointoh/backend/search.d.ts +9 -0
  41. package/lib/steps/upgrade/twopointoh/backend/search.js +28 -10
  42. package/lib/steps/upgrade/twopointoh/base.d.ts +38 -0
  43. package/lib/steps/upgrade/twopointoh/base.js +122 -24
  44. package/lib/steps/upgrade/twopointoh/dependencies.d.ts +58 -1
  45. package/lib/steps/upgrade/twopointoh/dependencies.js +343 -15
  46. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.d.ts +86 -0
  47. package/lib/steps/upgrade/twopointoh/frontend/admin-extender.js +842 -0
  48. package/lib/steps/upgrade/twopointoh/frontend/code-splitting/usage-of-lazy-modules.js +110 -18
  49. package/lib/steps/upgrade/twopointoh/frontend/export-registry/import-ext.js +13 -16
  50. package/lib/steps/upgrade/twopointoh/frontend/misc.d.ts +23 -0
  51. package/lib/steps/upgrade/twopointoh/frontend/misc.js +147 -8
  52. package/lib/steps/upgrade/twopointoh/infra.d.ts +16 -1
  53. package/lib/steps/upgrade/twopointoh/infra.js +87 -5
  54. package/lib/steps/upgrade/twopointoh/step-failures.d.ts +32 -0
  55. package/lib/steps/upgrade/twopointoh/step-failures.js +54 -0
  56. package/lib/steps/upgrade/twopointoh/todos.d.ts +22 -0
  57. package/lib/steps/upgrade/twopointoh/todos.js +57 -0
  58. package/lib/steps/upgrade/twopointoh/typescript-4-9.d.ts +12 -0
  59. package/lib/steps/upgrade/twopointoh/typescript-4-9.js +200 -0
  60. package/lib/utils/ast.js +5 -10
  61. package/lib/utils/babel-ts.d.ts +9 -0
  62. package/lib/utils/babel-ts.js +18 -0
  63. package/lib/utils/composer.d.ts +5 -0
  64. package/lib/utils/composer.js +10 -1
  65. package/lib/utils/terminal.d.ts +13 -0
  66. package/lib/utils/terminal.js +26 -0
  67. package/oclif.manifest.json +1 -1
  68. package/package.json +1 -1
  69. package/php-subsystem/index.php +12 -0
  70. package/php-subsystem/src/NodeUtil.php +19 -9
  71. package/php-subsystem/src/NodeVisitors/AddExtender.php +49 -1
  72. package/php-subsystem/src/Upgrade/TwoPointOh/JsonApi.php +2 -20
  73. package/php-subsystem/src/Upgrade/TwoPointOh/Misc.php +13 -17
  74. package/php-subsystem/src/Upgrade/TwoPointOh/ModelFactories.php +943 -0
  75. package/php-subsystem/src/Upgrade/TwoPointOh/PhpUnit.php +476 -71
  76. package/php-subsystem/src/Upgrade/TwoPointOh/Search.php +97 -13
@@ -11,13 +11,14 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
11
11
  super(...arguments);
12
12
  this.type = 'Adapt to accessing/extending lazy loaded Flarum modules.';
13
13
  // Array.from(flarum.reg.chunks.entries().flatMap(e => e[1].modules.map(m => e[1].namespace + ':' + m))).join('\n')
14
+ // LabelValue and LoadingPost were lazy once, and are in the main bundle
15
+ // now (flarum/framework#4235), so they are imported as usual.
14
16
  // core:forum/components/ReplyComposer
15
17
  // core:forum/components/ComposerBody
16
18
  // core:common/components/ConfirmDocumentUnload
17
19
  // core:forum/components/LogInModal
18
20
  // core:forum/components/ForgotPasswordModal
19
21
  // core:forum/components/PostStream
20
- // core:forum/components/LoadingPost
21
22
  // core:forum/components/ReplyPlaceholder
22
23
  // core:forum/components/PostStreamScrubber
23
24
  // core:forum/components/SearchModal
@@ -36,7 +37,6 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
36
37
  // core:forum/components/AccessTokensList
37
38
  // core:forum/components/NewAccessTokenModal
38
39
  // core:forum/states/UserSecurityPageState
39
- // core:common/components/LabelValue
40
40
  // core:forum/components/NotificationsPage
41
41
  // core:forum/components/Composer
42
42
  // core:forum/components/ComposerButton
@@ -53,15 +53,11 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
53
53
  'ForgotPasswordModal',
54
54
  'NotificationsPage',
55
55
  'ComposerBody',
56
- 'ConfirmDocumentUnload',
57
- 'LoadingPost',
58
56
  'ReplyPlaceholder',
59
57
  'PostStreamScrubber',
60
- 'SearchModal',
61
58
  'SignUpModal',
62
59
  'DiscussionComposer',
63
60
  'EditPostComposer',
64
- 'ComposerBody',
65
61
  'LogInModal',
66
62
  'PostStream',
67
63
  'ReplyComposer',
@@ -75,16 +71,16 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
75
71
  'UserSecurityPageState',
76
72
  'ComposerButton',
77
73
  ],
78
- common: ['EditUserModal', 'LabelValue', 'ConfirmDocumentUnload'],
74
+ common: ['EditUserModal', 'ConfirmDocumentUnload', 'SearchModal'],
79
75
  },
80
76
  'flarum/tags': {
81
77
  admin: [],
82
- forum: ['TagDiscussionModal', 'TagSelectionModal', 'ToggleButton'],
83
- common: [],
78
+ forum: ['TagDiscussionModal', 'ToggleButton'],
79
+ common: ['TagSelectionModal'],
84
80
  },
85
81
  'flarum/emoji': {
86
82
  admin: [],
87
- forum: ['EmojiMap'],
83
+ forum: ['emojiMap'],
88
84
  common: [],
89
85
  },
90
86
  current: {
@@ -184,11 +180,16 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
184
180
  updateComposerLoad() {
185
181
  return (_file, code, advanced) => {
186
182
  const ast = advanced;
183
+ let addsOverride = false;
187
184
  this.forEachLazyModule(code, ({ module, importPath }) => {
188
185
  // before = app.composer.load(${module}, { ... }, ...);
189
186
  // after = app.composer.load(() => import('${importPath}'), { ... }, ...);
190
187
  const lines = [];
191
- const composers = this.composers;
188
+ // A composer's prototype lines move into the then() of the file's own
189
+ // load of it, which has the module. Without one, they are given like
190
+ // any other lazy module's.
191
+ const movesToLoad = this.composers.includes(module) && loadsComposer(ast, module);
192
+ const overrideName = importedName(ast, 'flarum/common/extend', 'override');
192
193
  // pick up {module}.prototype... lines to add to the .then(() => { lines }) at the end of the call expression.
193
194
  (0, traverse_1.default)(ast, {
194
195
  ExpressionStatement(path) {
@@ -212,17 +213,18 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
212
213
  return;
213
214
  if (object.name !== module)
214
215
  return;
215
- if (composers.includes(module)) {
216
+ if (movesToLoad) {
216
217
  lines.push(node);
217
218
  path.remove();
218
219
  }
219
220
  else if ((t.isFunctionExpression(expression.right) || t.isArrowFunctionExpression(expression.right)) &&
220
- (t.isIdentifier(left.property) || t.isStringLiteral(left.property))) {
221
- // SettingsPage.prototype['draftsItems'] = ...;
222
- // change to extend('flarum/forum/components/SettingsPage', 'draftItems', ...);
221
+ ((t.isIdentifier(left.property) && !left.computed) || t.isStringLiteral(left.property))) {
222
+ // SettingsPage.prototype['draftsItems'] = function () {...};
223
+ // change to override('flarum/forum/components/SettingsPage', 'draftsItems', function (original) {...});
223
224
  const methodName = t.isIdentifier(left.property) ? left.property.name : left.property.value;
224
- const extend = t.callExpression(t.identifier('extend'), [t.stringLiteral(importPath), t.stringLiteral(methodName), expression.right]);
225
- path.replaceWith(t.expressionStatement(extend));
225
+ path.replaceWith(overrideMethod(overrideName !== null && overrideName !== void 0 ? overrideName : 'override', importPath, methodName, expression.right));
226
+ if (!overrideName)
227
+ addsOverride = true;
226
228
  }
227
229
  },
228
230
  });
@@ -307,6 +309,7 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
307
309
  });
308
310
  return {
309
311
  updated: ast,
312
+ imports: addsOverride ? [{ import: { name: 'override', path: 'flarum/common/extend', defaultImport: false } }] : [],
310
313
  };
311
314
  };
312
315
  }
@@ -349,10 +352,28 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
349
352
  }
350
353
  removeImportLines() {
351
354
  return (_file, code) => {
352
- this.forEachLazyModule(code, ({ module, importLine }) => {
355
+ this.forEachLazyModule(code, ({ module, importPath, importedAs, importLine }) => {
356
+ var _a, _b;
353
357
  if (code.includes(` extends ${module}`)) {
354
358
  return;
355
359
  }
360
+ // Types are erased from the build, so a type import costs nothing.
361
+ if (/^type\s/.test(importedAs.trim())) {
362
+ return;
363
+ }
364
+ // The other replacements load what they can as it's needed. Whatever
365
+ // still uses the import, such as JSX rendering the component, would
366
+ // break without it, and only the author knows where to load it. A name
367
+ // inside a string, such as a module path, is no use of it.
368
+ const names = (_b = (_a = importedAs.match(/[$A-Z_a-z][\w$]*/g)) === null || _a === void 0 ? void 0 : _a.filter((name) => name !== 'as' && name !== 'default')) !== null && _b !== void 0 ? _b : [];
369
+ const rest = code.replace(importLine, '');
370
+ if (names.some((name) => new RegExp(`(?<![\\w$./'"])${name.replace(/\$/g, '\\$')}(?![\\w$'"])`).test(rest))) {
371
+ const todo = `// @TODO: ${module} is lazy loaded in 2.0, so this import is undefined until its chunk has loaded. Load it with \`await import('${importPath}')\` before using it. https://docs.flarum.org/2.x/extend/code-splitting\n`;
372
+ if (!code.includes(todo + importLine)) {
373
+ code = code.replace(importLine, todo + importLine);
374
+ }
375
+ return;
376
+ }
356
377
  code = code
357
378
  // then remove the import.
358
379
  .replace(importLine, '');
@@ -416,3 +437,74 @@ class UsageOfLazyModules extends base_1.BaseUpgradeStep {
416
437
  }
417
438
  }
418
439
  exports.default = UsageOfLazyModules;
440
+ /** Whether the file loads the composer itself, with app.composer.load(Composer, ...). */
441
+ function loadsComposer(ast, module) {
442
+ let loads = false;
443
+ (0, traverse_1.default)(ast, {
444
+ CallExpression(path) {
445
+ const callee = path.node.callee;
446
+ const firstArg = path.node.arguments[0];
447
+ if (t.isMemberExpression(callee) &&
448
+ t.isIdentifier(callee.property, { name: 'load' }) &&
449
+ t.isMemberExpression(callee.object) &&
450
+ t.isIdentifier(callee.object.property, { name: 'composer' }) &&
451
+ t.isIdentifier(callee.object.object, { name: 'app' }) &&
452
+ t.isIdentifier(firstArg, { name: module })) {
453
+ loads = true;
454
+ path.stop();
455
+ }
456
+ },
457
+ });
458
+ return loads;
459
+ }
460
+ /** The name a file imports one of a module's exports by, if it does. */
461
+ function importedName(ast, source, name) {
462
+ for (const node of ast.program.body) {
463
+ if (!t.isImportDeclaration(node) || node.source.value !== source)
464
+ continue;
465
+ for (const specifier of node.specifiers) {
466
+ if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported, { name })) {
467
+ return specifier.local.name;
468
+ }
469
+ }
470
+ }
471
+ return null;
472
+ }
473
+ /** A name for a new binding in the function, that hides none it uses. */
474
+ function unusedName(node, name) {
475
+ const used = new Set();
476
+ t.traverseFast(node, (child) => {
477
+ if (t.isIdentifier(child))
478
+ used.add(child.name);
479
+ });
480
+ while (used.has(name))
481
+ name = '_' + name;
482
+ return name;
483
+ }
484
+ /**
485
+ * Module.prototype.method = fn; given with override() by the module's path,
486
+ * which replaces the method as the assignment did once the module loads. The
487
+ * original method comes first, after a TypeScript this parameter: a function
488
+ * that reads its arguments with `arguments` is called with them as they were.
489
+ */
490
+ function overrideMethod(override, importPath, methodName, method) {
491
+ const original = t.identifier(unusedName(method, 'original'));
492
+ let replacement;
493
+ let readsArguments = false;
494
+ t.traverseFast(method, (child) => {
495
+ if (t.isIdentifier(child, { name: 'arguments' }))
496
+ readsArguments = true;
497
+ });
498
+ if (t.isFunctionExpression(method) && readsArguments) {
499
+ const args = t.identifier(unusedName(method, 'args'));
500
+ const call = t.callExpression(t.memberExpression(method, t.identifier('apply')), [t.thisExpression(), args]);
501
+ replacement = t.functionExpression(null, [original, t.restElement(args)], t.blockStatement([t.returnStatement(call)]));
502
+ }
503
+ else {
504
+ const first = method.params[0];
505
+ const at = t.isIdentifier(first, { name: 'this' }) ? 1 : 0;
506
+ replacement = t.cloneNode(method);
507
+ replacement.params.splice(at, 0, original);
508
+ }
509
+ return t.expressionStatement(t.callExpression(t.identifier(override), [t.stringLiteral(importPath), t.stringLiteral(methodName), replacement]));
510
+ }
@@ -77,17 +77,12 @@ class ImportExt extends base_1.BaseUpgradeStep {
77
77
  updateImportsFromFlarumExtensions() {
78
78
  return (_file, code) => {
79
79
  // replace `import module from 'flarum/ext/module-path';` with `import module from 'ext:flarum/extension/module-path';`
80
- // extension must not be one of [forum, admin, common]
81
- const regex = new RegExp(/import\s+(\w+)\s+from\s+["'](flarum\/(?!\b(?:common|admin|forum|utils|helpers|components|models)\b).*\/[\w/]+)["']/gim);
82
- // First collect all the imports that are from extensions
83
- const imports = code.match(regex);
84
- if (imports === null || imports === void 0 ? void 0 : imports.length) {
85
- this.importingFromExtensions = [
86
- ...this.importingFromExtensions,
87
- ...imports.map((imp) => {
88
- return imp.match(/from ["'](flarum\/\w+).*\/([\w/]+)["']/)[1];
89
- }),
90
- ];
80
+ // wherever the code names a module: in imports and exports of any kind, import(), require(),
81
+ // and `declare module`, which augments the module. extension must not be one of [forum, admin, common].
82
+ const regex = /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bdeclare\s+module\s+|\bimport\s+)(["'])(flarum\/(?!\b(?:common|admin|forum|utils|helpers|components|models)\b)[\w-]+\/[^\s"']+)\2/gm;
83
+ // First collect all the extensions imported from
84
+ for (const match of code.matchAll(regex)) {
85
+ this.importingFromExtensions.push(match[3].split('/').slice(0, 2).join('/'));
91
86
  }
92
87
  // add todos to update imports that use other extensions
93
88
  const uniqueUseExtensions = [...new Set(this.useExtensions)];
@@ -96,7 +91,7 @@ class ImportExt extends base_1.BaseUpgradeStep {
96
91
  });
97
92
  return {
98
93
  updated: code
99
- .replace(regex, `import $1 from 'ext:$2'`)
94
+ .replace(regex, '$1$2ext:$3$2')
100
95
  // imports from @flarum/core no longer supported, replace with flarum/
101
96
  .replace(/import\s+(\w+)\s+from\s+["']@flarum\/core\/([\w/]+)["']/gim, `import $1 from 'flarum/$2'`),
102
97
  };
@@ -112,10 +107,12 @@ class ImportExt extends base_1.BaseUpgradeStep {
112
107
  (_a = advanced.compilerOptions).paths || (_a.paths = {});
113
108
  const paths = advanced.compilerOptions.paths;
114
109
  uniqueExtensions.forEach((ext) => {
115
- if (paths[ext]) {
116
- delete paths[ext];
117
- }
118
- paths[`ext:${ext}`] = [`../vendor/${ext}/js/dist-typings/*`];
110
+ var _a;
111
+ // Where the 1.x mapping found the typings, which isn't always vendor.
112
+ const typings = paths[`${ext}/*`];
113
+ delete paths[ext];
114
+ delete paths[`${ext}/*`];
115
+ paths[_a = `ext:${ext}/*`] || (paths[_a] = typings !== null && typings !== void 0 ? typings : [`../vendor/${ext}/js/dist-typings/*`]);
119
116
  });
120
117
  return {
121
118
  updated: advanced,
@@ -2,6 +2,13 @@ import { BaseUpgradeStep, GitCommit, Replacement } from '../base';
2
2
  export default class MiscFrontendChanges extends BaseUpgradeStep {
3
3
  type: string;
4
4
  replacements(file: string): Replacement[];
5
+ /**
6
+ * 2.0's AvatarEditor opens the file picker on click, and uploads a dropped
7
+ * image, whenever user.hasUploadedAvatar() is false. 1.x went by
8
+ * avatarUrl(), so an extension that gives users an avatar some other way,
9
+ * as a Gravatar one does, now gets an upload instead of its menu.
10
+ */
11
+ flagAvatarEditor(): Replacement;
5
12
  targets(): string[];
6
13
  gitCommit(): GitCommit;
7
14
  pauseMessage(): string;
@@ -11,7 +18,23 @@ export default class MiscFrontendChanges extends BaseUpgradeStep {
11
18
  private updateIndexPage;
12
19
  private updateAvatarIcon;
13
20
  private updateOtherRefs;
21
+ /**
22
+ * `app.initializers.has(name)` becomes `'id' in flarum.extensions`, where the
23
+ * upgrade knows the ID for certain: a bundled extension's, or one the
24
+ * extension's composer.json names. An initializer is not always named after
25
+ * its extension, so a check it can't be sure of gets a @TODO instead.
26
+ */
14
27
  private updateInitializers;
28
+ /**
29
+ * For `app.initializers.has('name')`: the ID of the extension it checks for,
30
+ * or null when the upgrade can't be sure of it. Undefined for anything else.
31
+ */
32
+ private checkedExtension;
33
+ private named;
34
+ /**
35
+ * The extensions the composer.json names, by package name and by ID.
36
+ */
37
+ private namedExtensions;
15
38
  private updateUploadImageButton;
16
39
  private updateExtendModal;
17
40
  }
@@ -5,11 +5,50 @@ const base_1 = require("../base");
5
5
  const chalk_1 = tslib_1.__importDefault(require("chalk"));
6
6
  const t = tslib_1.__importStar(require("@babel/types"));
7
7
  const traverse_1 = tslib_1.__importDefault(require("@babel/traverse"));
8
+ const fs_1 = tslib_1.__importDefault(require("fs"));
9
+ const path_1 = tslib_1.__importDefault(require("path"));
8
10
  const ast_1 = require("../../../../utils/ast");
11
+ const babel_ts_1 = require("../../../../utils/babel-ts");
12
+ const composer_1 = require("../../../../utils/composer");
13
+ /** The bundled extensions' IDs, which are their initializers' names in 2.0. */
14
+ const BUNDLED = new Set([
15
+ 'flarum-akismet',
16
+ 'flarum-approval',
17
+ 'flarum-audit',
18
+ 'flarum-bbcode',
19
+ 'flarum-deck',
20
+ 'flarum-embed',
21
+ 'flarum-emoji',
22
+ 'flarum-extension-manager',
23
+ 'flarum-flags',
24
+ 'flarum-gdpr',
25
+ 'flarum-lang-english',
26
+ 'flarum-likes',
27
+ 'flarum-lock',
28
+ 'flarum-markdown',
29
+ 'flarum-mentions',
30
+ 'flarum-messages',
31
+ 'flarum-nicknames',
32
+ 'flarum-pusher',
33
+ 'flarum-realtime',
34
+ 'flarum-statistics',
35
+ 'flarum-sticky',
36
+ 'flarum-subscriptions',
37
+ 'flarum-suspend',
38
+ 'flarum-tags',
39
+ ]);
40
+ /** The 1.x names of the bundled initializers 2.0 renamed (flarum/framework#4003). */
41
+ const RENAMED = {
42
+ lock: 'flarum-lock',
43
+ subscriptions: 'flarum-subscriptions',
44
+ 'flarum/nicknames': 'flarum-nicknames',
45
+ };
46
+ const CHECK_TODO = "@TODO: check whether the extension is enabled with '<its extension ID>' in flarum.extensions instead";
9
47
  class MiscFrontendChanges extends base_1.BaseUpgradeStep {
10
48
  constructor() {
11
49
  super(...arguments);
12
50
  this.type = 'Miscellaneous frontend changes.';
51
+ this.named = null;
13
52
  }
14
53
  // - [`IndexPage.prototype.sidebar` -> `IndexSidebar`](https://github.com/flarum/framework/blob/72f89c0209a5c4bbdc5482ecbdc2435dcd57550f/framework/core/js/src/forum/components/IndexSidebar.tsx)
15
54
  // - [`IndexPage.prototype.navItems` -> `IndexSidebar.prototype.navItems`](https://github.com/flarum/framework/blob/72f89c0209a5c4bbdc5482ecbdc2435dcd57550f/framework/core/js/src/forum/components/IndexSidebar.tsx)
@@ -31,8 +70,23 @@ class MiscFrontendChanges extends base_1.BaseUpgradeStep {
31
70
  this.updateInitializers(),
32
71
  this.updateUploadImageButton(),
33
72
  ...this.updateExtendModal(),
73
+ this.flagAvatarEditor(),
34
74
  ];
35
75
  }
76
+ /**
77
+ * 2.0's AvatarEditor opens the file picker on click, and uploads a dropped
78
+ * image, whenever user.hasUploadedAvatar() is false. 1.x went by
79
+ * avatarUrl(), so an extension that gives users an avatar some other way,
80
+ * as a Gravatar one does, now gets an upload instead of its menu.
81
+ */
82
+ flagAvatarEditor() {
83
+ return (file, code) => {
84
+ if (/["']flarum\/forum\/components\/AvatarEditor["']/.test(code)) {
85
+ this.leaveTodo(file, 'It uses AvatarEditor, which in 2.0 opens the file picker, or uploads a dropped image, whenever user.hasUploadedAvatar() is false, not when there is no avatarUrl(). Check what it does for users without an uploaded avatar.');
86
+ }
87
+ return null;
88
+ };
89
+ }
36
90
  targets() {
37
91
  return ['js/src/**/*'];
38
92
  }
@@ -258,8 +312,7 @@ class MiscFrontendChanges extends base_1.BaseUpgradeStep {
258
312
  return t.jsxAttribute(t.jsxIdentifier(key.toString()), t.isStringLiteral(prop.value) ? prop.value : t.jsxExpressionContainer(prop.value));
259
313
  }
260
314
  catch (error) {
261
- console.log({ [key.toString()]: prop.value });
262
- throw error;
315
+ throw new Error(`Could not turn the "${key.toString()}" attr into a JSX attribute: ${error.message}`);
263
316
  }
264
317
  });
265
318
  }
@@ -315,7 +368,8 @@ class MiscFrontendChanges extends base_1.BaseUpgradeStep {
315
368
  updated: code
316
369
  .replace(/this\.currentTag/g, 'app.currentTag')
317
370
  .replace('app.search.', 'app.search.state.')
318
- .replace('app.extensionData', 'app.registry')
371
+ .replace(/\bapp\.extensionData\b/g, 'app.registry')
372
+ .replace(/\b(app\.registry\s*\.\s*)getAllExtensionPermissions\b/g, '$1getAllPermissions')
319
373
  .replace("extend(BasicsPage.prototype, 'homePageItems'", "extend(BasicsPage, 'homePageItems'")
320
374
  .replace('className="NotificationList', 'className="HeaderList')
321
375
  .replace('className: "NotificationList', 'className: "HeaderList')
@@ -325,18 +379,103 @@ class MiscFrontendChanges extends base_1.BaseUpgradeStep {
325
379
  };
326
380
  };
327
381
  }
382
+ /**
383
+ * `app.initializers.has(name)` becomes `'id' in flarum.extensions`, where the
384
+ * upgrade knows the ID for certain: a bundled extension's, or one the
385
+ * extension's composer.json names. An initializer is not always named after
386
+ * its extension, so a check it can't be sure of gets a @TODO instead.
387
+ */
328
388
  updateInitializers() {
329
389
  return (_file, code) => {
330
- if (!code.includes('this.currentTag'))
390
+ if (!/initializers\s*\.\s*has\s*\(/.test(code))
391
+ return null;
392
+ const edits = [];
393
+ const flagged = new Set();
394
+ (0, traverse_1.default)((0, babel_ts_1.parse)(code), {
395
+ CallExpression: (p) => {
396
+ var _a;
397
+ const id = this.checkedExtension(p.node);
398
+ if (id === undefined)
399
+ return;
400
+ if (id) {
401
+ edits.push({ start: p.node.start, end: p.node.end, text: `('${id}' in flarum.extensions)` });
402
+ return;
403
+ }
404
+ // Above the statement, where a comment can't land in JSX or a string.
405
+ const statement = p.getStatementParent();
406
+ if (!statement || ((_a = statement.node.leadingComments) === null || _a === void 0 ? void 0 : _a.some((comment) => comment.value.includes(CHECK_TODO))))
407
+ return;
408
+ const lineStart = code.lastIndexOf('\n', statement.node.start - 1) + 1;
409
+ if (flagged.has(lineStart))
410
+ return;
411
+ flagged.add(lineStart);
412
+ const indent = /^[\t ]*/.exec(code.slice(lineStart))[0];
413
+ const docs = 'https://docs.flarum.org/2.x/extend/extending-extensions';
414
+ edits.push({
415
+ start: lineStart,
416
+ end: lineStart,
417
+ text: `${indent}// ${CHECK_TODO} (${docs}). An initializer is not always named after its extension, and some were renamed in 2.0.\n`,
418
+ });
419
+ },
420
+ });
421
+ if (edits.length === 0)
331
422
  return null;
332
423
  return {
333
- updated: code
334
- .replace(/initializers\.has\(["']lock["']\)/g, "initializers.has('flarum-lock')")
335
- .replace(/initializers\.has\(["']subscriptions["']\)/g, "initializers.has('flarum-subscriptions')")
336
- .replace(/initializers\.has\(["']flarum\/nicknames["']\)/g, "initializers.add('flarum-nicknames')"),
424
+ updated: edits
425
+ .sort((a, b) => b.start - a.start)
426
+ .reduce((result, edit) => result.slice(0, edit.start) + edit.text + result.slice(edit.end), code),
337
427
  };
338
428
  };
339
429
  }
430
+ /**
431
+ * For `app.initializers.has('name')`: the ID of the extension it checks for,
432
+ * or null when the upgrade can't be sure of it. Undefined for anything else.
433
+ */
434
+ checkedExtension(call) {
435
+ var _a, _b;
436
+ const callee = call.callee;
437
+ if (!t.isMemberExpression(callee) || callee.computed || !t.isIdentifier(callee.property, { name: 'has' }))
438
+ return undefined;
439
+ const initializers = callee.object;
440
+ if (!t.isMemberExpression(initializers) || initializers.computed || !t.isIdentifier(initializers.property, { name: 'initializers' })) {
441
+ return undefined;
442
+ }
443
+ const owner = initializers.object;
444
+ if (!t.isIdentifier(owner, { name: 'app' }) && !(t.isMemberExpression(owner) && t.isIdentifier(owner.property, { name: 'app' }))) {
445
+ return undefined;
446
+ }
447
+ const [name] = call.arguments;
448
+ if (call.arguments.length !== 1 || !t.isStringLiteral(name))
449
+ return undefined;
450
+ return (_a = RENAMED[name.value]) !== null && _a !== void 0 ? _a : (BUNDLED.has(name.value) ? name.value : (_b = this.namedExtensions().get(name.value)) !== null && _b !== void 0 ? _b : null);
451
+ }
452
+ /**
453
+ * The extensions the composer.json names, by package name and by ID.
454
+ */
455
+ namedExtensions() {
456
+ var _a, _b, _c, _d, _e, _f;
457
+ if (this.named)
458
+ return this.named;
459
+ this.named = new Map();
460
+ let composer = {};
461
+ try {
462
+ composer = JSON.parse(fs_1.default.readFileSync(path_1.default.join(this.root, 'composer.json'), 'utf8'));
463
+ }
464
+ catch (_g) {
465
+ return this.named;
466
+ }
467
+ const packages = [
468
+ ...Object.keys((_a = composer.require) !== null && _a !== void 0 ? _a : {}),
469
+ ...Object.keys((_b = composer['require-dev']) !== null && _b !== void 0 ? _b : {}),
470
+ ...Object.keys((_c = composer.suggest) !== null && _c !== void 0 ? _c : {}),
471
+ ...((_f = (_e = (_d = composer.extra) === null || _d === void 0 ? void 0 : _d['flarum-extension']) === null || _e === void 0 ? void 0 : _e['optional-dependencies']) !== null && _f !== void 0 ? _f : []),
472
+ ].filter((name) => typeof name === 'string' && name.includes('/'));
473
+ for (const name of packages) {
474
+ this.named.set(name, (0, composer_1.extensionId)(name));
475
+ this.named.set((0, composer_1.extensionId)(name), (0, composer_1.extensionId)(name));
476
+ }
477
+ return this.named;
478
+ }
340
479
  updateUploadImageButton() {
341
480
  return (file, code) => {
342
481
  if (!code.includes('UploadImageButton'))
@@ -1,10 +1,25 @@
1
- import { BaseUpgradeStep, GitCommit, Replacement } from './base';
1
+ import { AdvancedContent, BaseUpgradeStep, GitCommit, Replacement } from './base';
2
2
  export default class Infrastructure extends BaseUpgradeStep {
3
3
  type: string;
4
4
  replacements(file: string): Replacement[];
5
5
  targets(): string[];
6
+ advancedContent(_file: string, _code: string): AdvancedContent;
6
7
  gitCommit(): GitCommit;
7
8
  pauseMessage(): string;
8
9
  updateWorkflow(): Replacement;
10
+ /**
11
+ * The reusable frontend workflow commits the built JS to `main_git_branch`,
12
+ * which still names 1.x. Point it at a 2.x branch when there is one. Other
13
+ * values are left alone: an extension's 2.x code is often on main.
14
+ */
15
+ updateMainGitBranch(code: string): Promise<string>;
16
+ /** Whether the extension's repository has the branch, locally or on a remote. */
17
+ protected hasBranch(name: string): Promise<boolean>;
18
+ /**
19
+ * Read the global typings of every Flarum package in vendor, as a new
20
+ * extension does, not only core's. The file is edited as text so its
21
+ * comments stay.
22
+ */
23
+ updateTsConfigInclude(): Replacement;
9
24
  updatePhpStan(): Replacement;
10
25
  }
@@ -1,6 +1,40 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const simple_git_1 = tslib_1.__importDefault(require("simple-git"));
3
5
  const base_1 = require("./base");
6
+ // Where 1.x extensions got core's global typings from, and where 2.x ones get
7
+ // the global typings of every Flarum package they depend on.
8
+ const CORE_GLOBAL_TYPINGS = '"../vendor/flarum/core/js/dist-typings/@types/**/*"';
9
+ const VENDOR_GLOBAL_TYPINGS = '"../vendor/*/*/js/dist-typings/@types/**/*"';
10
+ const indentation = (line) => line.length - line.trimStart().length;
11
+ /**
12
+ * Drop a workflow's `php_versions` input, so the reusable workflow tests the
13
+ * PHP versions Flarum 2.0 supports and keeps up as that changes. A `with:`
14
+ * left without inputs goes too.
15
+ */
16
+ function dropPhpVersions(code) {
17
+ const lines = code.split('\n');
18
+ const kept = [];
19
+ for (let i = 0; i < lines.length; i++) {
20
+ if (!/^\s*php_versions:/.test(lines[i])) {
21
+ kept.push(lines[i]);
22
+ continue;
23
+ }
24
+ // A folded or literal value continues on the more indented lines below.
25
+ while (i + 1 < lines.length && lines[i + 1].trim() !== '' && indentation(lines[i + 1]) > indentation(lines[i])) {
26
+ i++;
27
+ }
28
+ }
29
+ return kept
30
+ .filter((line, i) => {
31
+ if (!/^\s*with:\s*(#.*)?$/.test(line))
32
+ return true;
33
+ const next = kept.slice(i + 1).find((other) => other.trim() !== '');
34
+ return next !== undefined && indentation(next) > indentation(line);
35
+ })
36
+ .join('\n');
37
+ }
4
38
  class Infrastructure extends base_1.BaseUpgradeStep {
5
39
  constructor() {
6
40
  super(...arguments);
@@ -14,10 +48,18 @@ class Infrastructure extends base_1.BaseUpgradeStep {
14
48
  if (file.endsWith('.neon')) {
15
49
  replacements.push(this.updatePhpStan());
16
50
  }
51
+ if (file === 'js/tsconfig.json') {
52
+ replacements.push(this.updateTsConfigInclude());
53
+ }
17
54
  return replacements;
18
55
  }
19
56
  targets() {
20
- return ['.github/workflows/*.yml', 'phpstan.neon'];
57
+ return ['.github/workflows/*.yml', 'phpstan.neon', 'js/tsconfig.json'];
58
+ }
59
+ // Every file is only edited as text: a tsconfig.json the JSON parser can't
60
+ // read shouldn't stop the workflows being updated.
61
+ advancedContent(_file, _code) {
62
+ return null;
21
63
  }
22
64
  gitCommit() {
23
65
  return {
@@ -29,15 +71,55 @@ class Infrastructure extends base_1.BaseUpgradeStep {
29
71
  return 'Infrastructure updated. Please review the changes then proceed.';
30
72
  }
31
73
  updateWorkflow() {
32
- return (_file, code) => ({
33
- updated: code
74
+ return async (_file, code) => ({
75
+ updated: await this.updateMainGitBranch(dropPhpVersions(code
34
76
  .replace('REUSABLE_backend.yml@1.x', 'REUSABLE_backend.yml@2.x')
35
77
  .replace('REUSABLE_frontend.yml@1.x', 'REUSABLE_frontend.yml@2.x')
36
78
  .replace('REUSABLE_backend.yml@main', 'REUSABLE_backend.yml@2.x')
37
- .replace('REUSABLE_frontend.yml@main', 'REUSABLE_frontend.yml@2.x')
38
- .replace(/php_versions: '\["[^']+]'/, 'php_versions: \'["8.2", "8.3", "8.4"]\''),
79
+ .replace('REUSABLE_frontend.yml@main', 'REUSABLE_frontend.yml@2.x'))),
39
80
  });
40
81
  }
82
+ /**
83
+ * The reusable frontend workflow commits the built JS to `main_git_branch`,
84
+ * which still names 1.x. Point it at a 2.x branch when there is one. Other
85
+ * values are left alone: an extension's 2.x code is often on main.
86
+ */
87
+ async updateMainGitBranch(code) {
88
+ const setting = /^([\t ]*)main_git_branch:([\t ]*)(["']?)1\.x\3/m;
89
+ const match = code.match(setting);
90
+ if (!match)
91
+ return code;
92
+ if (await this.hasBranch('2.x')) {
93
+ return code.replace(setting, (_line, indent, space, quote) => `${indent}main_git_branch:${space}${quote}2.x${quote}`);
94
+ }
95
+ const todo = `${match[1]}# @TODO: Set main_git_branch to the branch your Flarum 2.x code is on. The built JS is committed to it.`;
96
+ // An earlier run already left it.
97
+ if (code.includes(todo))
98
+ return code;
99
+ return code.replace(setting, (line) => `${todo}\n${line}`);
100
+ }
101
+ /** Whether the extension's repository has the branch, locally or on a remote. */
102
+ async hasBranch(name) {
103
+ try {
104
+ const branches = await (0, simple_git_1.default)(this.root).branch(['--all']);
105
+ return branches.all.some((branch) => branch === name || (branch.startsWith('remotes/') && branch.endsWith(`/${name}`)));
106
+ }
107
+ catch (_a) {
108
+ return false;
109
+ }
110
+ }
111
+ /**
112
+ * Read the global typings of every Flarum package in vendor, as a new
113
+ * extension does, not only core's. The file is edited as text so its
114
+ * comments stay.
115
+ */
116
+ updateTsConfigInclude() {
117
+ return (_file, code) => {
118
+ if (!code.includes(CORE_GLOBAL_TYPINGS) || code.includes(VENDOR_GLOBAL_TYPINGS))
119
+ return null;
120
+ return { updated: code.replace(CORE_GLOBAL_TYPINGS, VENDOR_GLOBAL_TYPINGS) };
121
+ };
122
+ }
41
123
  updatePhpStan() {
42
124
  return (_file, code) => ({
43
125
  updated: code