├── bin
└── apos-code-upgrader
├── lib
├── fail.js
├── get.js
├── glob-by-extension.js
├── legacy-module-name-map.js
├── linter.js
├── linters.js
└── upgrader.js
├── .eslintrc
├── dump.js
├── notes.txt
├── test
└── helpers.js
├── .circleci
└── config.yml
├── helpers
└── index.js
├── .gitignore
├── package.json
├── LICENSE.md
├── CHANGELOG.md
├── app.js
└── README.md
/bin/apos-code-upgrader:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('../app.js');
4 |
--------------------------------------------------------------------------------
/lib/fail.js:
--------------------------------------------------------------------------------
1 | module.exports = (s) => {
2 | console.error(s);
3 | process.exit(1);
4 | };
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "apostrophe",
3 | "ignorePatterns": [
4 | "node_modules/"
5 | ],
6 | "rules": {
7 | "no-console": 0
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/dump.js:
--------------------------------------------------------------------------------
1 | const moduleName = process.argv[2];
2 | const code = require('fs').readFileSync(moduleName, 'utf8');
3 | const acorn = require('acorn');
4 | const parsed = acorn.parse(code);
5 |
6 | console.info(JSON.stringify(parsed, null, ' '));
7 |
--------------------------------------------------------------------------------
/notes.txt:
--------------------------------------------------------------------------------
1 | ✅ * addHelpers calls are still not converting. Variations worth converting:
2 | ✅ * addHelpers({ object ... })
3 | ✅ * addHelpers(_.pick(self, some, method, names)) -> helpers: [ 'some', 'method', 'names' ]
4 |
5 | We can support this for any section.
6 |
--------------------------------------------------------------------------------
/lib/get.js:
--------------------------------------------------------------------------------
1 | module.exports = (o, s) => {
2 | if (o == null) {
3 | return null;
4 | }
5 |
6 | const clauses = s.split(/\./);
7 |
8 | for (const c of clauses) {
9 | if (o[c] == null) {
10 | return null;
11 | }
12 | o = o[c];
13 | }
14 | return o;
15 | };
16 |
--------------------------------------------------------------------------------
/lib/glob-by-extension.js:
--------------------------------------------------------------------------------
1 | const glob = require('glob');
2 | const ignore = [
3 | 'dist/**/*',
4 | '**/node_modules/**',
5 | '**/public/**/*',
6 | '**/private/**/*',
7 | 'apos-build/**/*'
8 | ];
9 |
10 | module.exports = {
11 | globByExtension (extension) {
12 | return glob.sync(`**/*.${extension}`, { ignore });
13 | },
14 | globByPattern (pattern) {
15 | return glob.sync(pattern, { ignore });
16 | }
17 | };
18 |
--------------------------------------------------------------------------------
/test/helpers.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const helpers = require('../helpers/index.js');
3 |
4 | describe('helpers work', () => {
5 | it('handles array option to object conversion', () => {
6 | const converted = helpers.arrayOptionToObject([
7 | {
8 | name: 'job',
9 | type: 'string',
10 | title: 'Job'
11 | }
12 | ]);
13 | assert(!Array.isArray(converted));
14 | assert((typeof converted) === 'object');
15 | assert(converted.job.type === 'string');
16 | assert(converted.job.title === 'Job');
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/node:12-browsers
6 | - image: mongo:latest
7 | steps:
8 | - checkout
9 | - run:
10 | name: update-npm
11 | command: 'sudo npm install -g npm@6'
12 | - restore_cache:
13 | key: dependency-cache-{{ checksum "package.json" }}
14 | - run:
15 | name: install-npm-wee
16 | command: npm install
17 | - save_cache:
18 | key: dependency-cache-{{ checksum "package.json" }}
19 | paths:
20 | - ./node_modules
21 | - run:
22 | name: test
23 | command: npm test
24 |
--------------------------------------------------------------------------------
/helpers/index.js:
--------------------------------------------------------------------------------
1 | // This file was copied into your project by the 2.x to 3.x
2 | // code migration tool. It helps with things the tool can't
3 | // convert in the source code.
4 | //
5 | // You can remove it when none of your modules require it anymore.
6 |
7 | module.exports = {
8 | // Invoked by converted code when schema field arrays are defined
9 | // somewhere other than directly in the addFields option.
10 | // You should update your code to avoid the need for this function ASAP.
11 | arrayOptionToObject(fields) {
12 | return Object.fromEntries(
13 | fields.map(field => {
14 | const withoutName = Object.fromEntries(Object.entries(field).filter(([ name, value ]) => name !== 'name'));
15 | return [ field.name, withoutName ];
16 | })
17 | );
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | package-lock.json
2 | test/public/modules/*
3 | test/public/css/master-*
4 | npm-debug.log
5 | *.DS_Store
6 | *.npmignore
7 | node_modules
8 | test/node_modules
9 |
10 | # We do not commit CSS, only LESS, with the exception of a few vendor CSS files we don't have LESS for
11 | */public/css/*.css
12 | lib/modules/*/public/css/*.css
13 | */public/css/*.css
14 | lib/modules/*/public/css/*.css
15 | lib/modules/apostrophe-assets/public/css/vendor/cropper.css
16 | lib/modules/apostrophe-assets/public/css/vendor/pikaday.css
17 | lib/modules/apostrophe-ui/public/css/vendor/font-awesome/font-awesome.css
18 | # Never commit a CSS map file, anywhere
19 | *.css.map
20 |
21 | # Dont commit test generated css
22 | test/public/css/*.css
23 | test/public/css/master-*.less
24 |
25 | # Dont commit test uploads
26 | test/public/uploads
27 |
28 | # vim swp files
29 | .*.sw*
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apostrophecms/code-upgrader",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "test": "eslint . && mocha"
8 | },
9 | "author": "Apostrophe Technologies, Inc.",
10 | "license": "MIT",
11 | "dependencies": {
12 | "acorn": "^8.0.0",
13 | "boring": "^1.0.0",
14 | "common-tags": "^1.8.1",
15 | "escodegen": "^2.0.0",
16 | "glob": "^7.2.0",
17 | "prompts": "^2.4.2",
18 | "string.prototype.replaceall": "^1.0.6"
19 | },
20 | "devDependencies": {
21 | "eslint": "^8.0.0",
22 | "eslint-config-apostrophe": "^3.4.1",
23 | "eslint-config-standard": "^16.0.3",
24 | "eslint-plugin-node": "^11.1.0",
25 | "eslint-plugin-promise": "^5.1.1",
26 | "mocha": "^9.1.3"
27 | },
28 | "bin": {
29 | "apos-code-upgrader": "./bin/apos-code-upgrader"
30 | },
31 | "publishConfig": {
32 | "access": "public"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2021 Apostrophe Technologies, Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## UNRELEASED
4 |
5 | ### Changes
6 |
7 | - Updates the documentation. No code changes.
8 |
9 | ## 1.0.0 (2023-03-16)
10 |
11 | - Declared stable. No code changes.
12 |
13 | ## 1.0.0-beta.2 (2023-02-01)
14 |
15 | - `upgrade` command now also supports A2 projects powered by `apostrophe-multsite`.
16 |
17 | ## 1.0.0-beta (2022-01-21)
18 |
19 | - Linter now looks for `webpack.config.js` and explains possible alternatives and options.
20 |
21 | ## 1.0.0-alpha.2 (2021-12-08)
22 |
23 | - Adds a prompt before the upgrade script runs to confirm the user wants to proceed.
24 | - Adds helpful message about using "git diff HEAD" after running the upgrade command.
25 | - Display lint messages in source code order, even if they are for a mix of issues.
26 | - Lint for array field schema properties that need conversion.
27 | - Lint for joinByArray field sub-schema properties that need conversion.
28 | - Lint for joinByOne, joinByArray, joinbyOneReverse, and joinByArrayReverse.
29 | - Lint for widget output method overrides.
30 | - Lint for `filterOptionsForDataAttribute`.
31 | - Lint for methods that should move to the "methods" section.
32 | - Lint for tasks that should move to the "tasks" section.
33 | - Lint for `self.route`, `self.apiRoute`, `self.htmlRoute`, and `self.renderRoute`.
34 |
35 | ## 1.0.0-alpha.1 (2021-12-03)
36 |
37 | - Adds missing `glob` npm dependency needed for `npm install -g`. Previously a transient dependency allowed this to work in some cases but not all.
38 |
39 | ## 1.0.0-alpha (2021-11-23)
40 |
41 | - First alpha release.
42 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const argv = require('boring')();
2 | const cp = require('child_process');
3 | const linter = require('./lib/linter');
4 | const upgrader = require('./lib/upgrader');
5 | const { stripIndent } = require('common-tags');
6 | const prompts = require('prompts');
7 |
8 | if (argv._[0] === 'reset') {
9 | cp.execSync('git reset --hard && git clean -df');
10 | } else if (argv._[0] === 'lint') {
11 | linter({ argv });
12 | } else if (argv._[0] === 'upgrade') {
13 | upgrade({ argv });
14 | } else if (argv._[0] === 'help' || argv.help) {
15 | console.log(stripIndent`
16 | Commands:
17 |
18 | lint List changes you need to make (always recommended)
19 | Example: apos-code-upgrader lint
20 | upgrade [options] Make some changes automatically
21 | Example: apos-code-upgrader upgrade
22 | Options:
23 | - --upgrade-required-files: Experimental support for inlining module
24 | code included with \`require\` statements. Most successful when the
25 | inlined file is limited to an exported function that the module
26 | invokes with \`(self, options)\`
27 | `);
28 | } else {
29 | console.log(stripIndent`
30 | Run \`apos-code-upgrader help\` or \`apos-code-upgrader --help\` for commands.
31 | `);
32 | }
33 |
34 | async function upgrade({ argv }) {
35 | const { proceed } = await confirm();
36 |
37 | if (proceed) {
38 | upgrader({ argv });
39 | } else {
40 | process.exit(1);
41 | }
42 | }
43 |
44 | async function confirm() {
45 | return prompts({
46 | type: 'confirm',
47 | name: 'proceed',
48 | message: stripIndent`
49 | Running the upgrade command will make MAJOR changes to your code. It will not (git) commit those changes, however.
50 |
51 | Are you sure you want to continue?`,
52 | initial: false
53 | });
54 | }
55 |
--------------------------------------------------------------------------------
/lib/legacy-module-name-map.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'apostrophe-multisite': '@apostrophecms-pro/multisite',
3 | 'apostrophe-utils': '@apostrophecms/util',
4 | 'apostrophe-tasks': '@apostrophecms/task',
5 | 'apostrophe-launder': '@apostrophecms/launder',
6 | 'apostrophe-i18n': '@apostrophecms/i18n',
7 | 'apostrophe-db': '@apostrophecms/db',
8 | 'apostrophe-locks': '@apostrophecms/lock',
9 | // TODO linter: we have to point out that the
10 | // cache API has also changed, in ways we probably
11 | // can't automatically rewrite
12 | 'apostrophe-caches': '@apostrophecms/cache',
13 | 'apostrophe-migrations': '@apostrophecms/migration',
14 | 'apostrophe-express': '@apostrophecms/express',
15 | 'apostrophe-urls': '@apostrophecms/url',
16 | 'apostrophe-templates': '@apostrophecms/template',
17 | 'apostrophe-email': '@apostrophecms/email',
18 | 'apostrophe-push': '@apostrophecms/push',
19 | 'apostrophe-permissions': '@apostrophecms/permission',
20 | 'apostrophe-assets': '@apostrophecms/asset',
21 | 'apostrophe-admin-bar': '@apostrophecms/admin-bar',
22 | 'apostrophe-login': '@apostrophecms/login',
23 | 'apostrophe-notifications': '@apostrophecms/notification',
24 | 'apostrophe-schemas': '@apostrophecms/schema',
25 | 'apostrophe-docs': '@apostrophecms/doc',
26 | 'apostrophe-jobs': '@apostrophecms/job',
27 | 'apostrophe-attachments': '@apostrophecms/attachment',
28 | 'apostrophe-oembed': '@apostrophecms/oembed',
29 | 'apostrophe-pager': '@apostrophecms/pager',
30 | 'apostrophe-global': '@apostrophecms/global',
31 | 'apostrophe-polymorphic-manager': '@apostrophecms/polymorphic-type',
32 | 'apostrophe-pages': '@apostrophecms/page',
33 | 'apostrophe-search': '@apostrophecms/search',
34 | 'apostrophe-any-page-manager': '@apostrophecms/any-page-type',
35 | 'apostrophe-areas': '@apostrophecms/area',
36 | 'apostrophe-rich-text-widgets': '@apostrophecms/rich-text-widget',
37 | 'apostrophe-html-widgets': '@apostrophecms/html-widget',
38 | 'apostrophe-video-widgets': '@apostrophecms/video-widget',
39 | // TODO flag apostrophe-groups as an area for an enterprise conversation
40 | 'apostrophe-users': '@apostrophecms/user',
41 | 'apostrophe-images': '@apostrophecms/image',
42 | // TODO linter must flag potential loss of content here as the old
43 | // widget was a slideshow and the new one only handles one image
44 | // (our content migrator also tries to figure this out)
45 | 'apostrophe-images-widgets': '@apostrophecms/image-widget',
46 | 'apostrophe-files': '@apostrophecms/file',
47 | 'apostrophe-module': '@apostrophecms/module',
48 | 'apostrophe-widgets': '@apostrophecms/widget-type',
49 | 'apostrophe-custom-pages': '@apostrophecms/page-type',
50 | 'apostrophe-pieces': '@apostrophecms/piece-type',
51 | 'apostrophe-pieces-pages': '@apostrophecms/piece-page-type',
52 | // TODO linter must flag apostrophe-pieces-widgets or reinvent it
53 | 'apostrophe-doc-type-manager': '@apostrophecms/doc-type'
54 | };
55 |
--------------------------------------------------------------------------------
/lib/linter.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const acorn = require('acorn');
3 | const glob = require('glob');
4 | const stripIndents = require('common-tags').stripIndents;
5 |
6 | const linters = require('./linters.js');
7 | const get = require('./get.js');
8 | const { globByExtension } = require('./glob-by-extension.js');
9 |
10 | module.exports = ({ argv }) => {
11 | lintWebpack();
12 | const extensions = Object.keys(linters);
13 | let matched = false;
14 | for (const extension of extensions) {
15 | if (extension === 'module') {
16 | lintModules();
17 | } else {
18 | const files = globByExtension(extension);
19 | files.forEach(file => lint(extension, file));
20 | }
21 | }
22 | if (matched) {
23 | process.exit(1);
24 | }
25 |
26 | function lintModules() {
27 | const matchedModuleRule = {};
28 | const modules = [ ...glob.sync('lib/modules/*'), ...glob.sync('lib/modules/@*/*'), ...glob.sync('modules/*'), ...glob.sync('modules/@*/*') ].filter(path => fs.lstatSync(path).isDirectory());
29 | modules.forEach(module => lintModule(module));
30 | function lintModule(module) {
31 | for (const [ name, options ] of Object.entries(linters.module)) {
32 | if (options.once && matchedModuleRule[name]) {
33 | continue;
34 | }
35 | if (options.matchName) {
36 | if (matchRule(moduleName(module), options.matchName).length) {
37 | report(module, name, options);
38 | }
39 | } else if (options.matchFilename) {
40 | if (matchRule(module, options.matchFilename).length) {
41 | report(module, name, options);
42 | }
43 | }
44 | }
45 | }
46 | function report(module, name, options) {
47 | matchedModuleRule[name] = true;
48 | console.error(`${module}:\n`);
49 | console.error(getMessage({
50 | name: moduleName(module),
51 | filename: module
52 | }, options.message));
53 | console.error();
54 | }
55 | }
56 |
57 | function lint(extension, file) {
58 | const src = fs.readFileSync(file, { encoding: 'utf8' });
59 | let parsed;
60 |
61 | const results = [];
62 |
63 | if (file.match(/\.js$/)) {
64 | let ast;
65 | try {
66 | ast = acorn.parse(src, {
67 | locations: true,
68 | ranges: true,
69 | ecmaVersion: 2020
70 | });
71 | } catch (e) {
72 | console.error(`Could not parse ${file}, some linter tests will not be available.`);
73 | }
74 |
75 | const exportsStatement = ast && ast.body.find(statement => {
76 | return (get(statement, 'expression.left.object.name') === 'module') &&
77 | (get(statement, 'expression.left.property.name') === 'exports');
78 | });
79 | parsed = {
80 | ast,
81 | exports: {}
82 | };
83 | if (exportsStatement) {
84 | const moduleBody = get(exportsStatement, 'expression.right');
85 | if (get(moduleBody, 'type') === 'ObjectExpression') {
86 | get(moduleBody, 'properties').forEach(property => {
87 | const name = get(property, 'key.name');
88 | const value = get(property, 'value');
89 | parsed.exports[name] = value;
90 | });
91 | }
92 | }
93 | }
94 |
95 | for (const options of Object.values(linters[extension])) {
96 | let matches = [];
97 | if (options.matchParsed && parsed.ast) {
98 | matches = options.matchParsed(parsed);
99 | if (matches) {
100 | if (!Array.isArray(matches)) {
101 | matches = [ matches ];
102 | }
103 | } else {
104 | matches = [];
105 | }
106 | } else if (options.match) {
107 | matches = matchRule(src, options.match);
108 | }
109 | for (const match of matches) {
110 | // Don't mess up if index is 0
111 | const line = indexToLine(src, (match.index !== undefined) ? match.index : match.start);
112 | results.push({
113 | lineNumber: line.lineNumber,
114 | // stripIndent just doesn't work here and I don't know why
115 | error: `
116 | ${file}, line ${line.lineNumber}:
117 |
118 | ${line.text}
119 | ${' '.repeat(line.columnNumber - 1) + '^'}
120 | ${getMessage({
121 | src,
122 | line,
123 | match
124 | }, options.message)}
125 | ${options.url ? `\nSee: ${options.url}\n` : ''}
126 | `.trim()
127 | });
128 | matched = true;
129 | }
130 | }
131 |
132 | results.sort((a, b) => {
133 | if (a.lineNumber < b.lineNumber) {
134 | return -1;
135 | } else if (a.lineNumber > b.lineNumber) {
136 | return 1;
137 | } else {
138 | return 0;
139 | }
140 | });
141 |
142 | for (const result of results) {
143 | console.error(result.error + '\n');
144 | }
145 | }
146 |
147 | function indexToLine(src, index) {
148 | let text = '';
149 | let lineNumber = 1;
150 | let columnNumber = 1;
151 | let i = 0;
152 | let found = false;
153 | for (i = 0; (i < src.length); i++) {
154 | if (i === index) {
155 | found = true;
156 | }
157 | const char = src.charAt(i);
158 | if (char === '\n') {
159 | if (found) {
160 | break;
161 | }
162 | lineNumber++;
163 | columnNumber = 1;
164 | text = '';
165 | } else {
166 | if (!found) {
167 | columnNumber++;
168 | }
169 | text += char;
170 | }
171 | }
172 | if (found) {
173 | return {
174 | text,
175 | lineNumber,
176 | columnNumber
177 | };
178 | } else {
179 | throw new Error('Could not find index in string');
180 | }
181 | }
182 | };
183 |
184 | function moduleName(path) {
185 | if (path.includes('@')) {
186 | return path.substring(path.lastIndexOf('@'), path.length);
187 | } else if (path.includes('/')) {
188 | return path.substring(path.lastIndexOf('/') + 1, path.length);
189 | } else {
190 | // Standalone module, we were passed the module name
191 | return path;
192 | }
193 | }
194 |
195 | function matchRule(s, rule) {
196 | if ((typeof rule) === 'function') {
197 | const result = rule(s);
198 | return result ? [ result ] : [];
199 | } else if (rule.global) {
200 | return [ ...s.matchAll(rule) ];
201 | } else {
202 | const result = s.match(rule);
203 | return result ? [ result ] : [];
204 | }
205 | }
206 |
207 | function getMessage(input, message) {
208 | const formatted = ((typeof message) === 'function') ? message(input) : message;
209 | return formatted.trim();
210 | }
211 |
212 | function lintWebpack() {
213 | if (fs.existsSync('./webpack.config.js')) {
214 | console.error(stripIndents`
215 | This project contains a webpack.config.js file. Depending on your needs
216 | this might not be necessary with A3.
217 |
218 | See:
219 |
220 | https://v3.docs.apostrophecms.org/guide/front-end-assets.html
221 | `);
222 | }
223 | }
224 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://circleci.com/gh/apostrophecms/code-upgrader/tree/master)
2 | [](https://chat.apostrophecms.org)
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
Apostrophe Code Upgrader: ⬆︎A2
11 |
12 |
13 | The Code Upgrader handles a portion of the required modifications for an Apostrophe 2 (A2) codebase to run newer Apostrophe versions. It will also identify many specific lines and sections of code that a developer will need to convert manually.
14 |
15 | **Status:** In development (not for production use)
16 |
17 | ## Purpose
18 |
19 | ### What it does
20 |
21 | This module's features break down into two basic categories: "linting" for compatibility issues you can fix yourself, and "upgrading" code automatically. Since not everything can be upgraded automatically, the linting feature is important for everyone.
22 |
23 | #### Linting for compatibility issues
24 |
25 | This module's linting feature scans your project for modifications that likely need to be made to be compatible with newer versions of Apostrophe. The lint command will work well with basically all projects and detects many issues. Here are just a few examples of what the linter can detect:
26 |
27 | - The need to rename `lib/modules` to `modules`.
28 | - The need to change `{{ apos.area(...) }}` to `{% area ... %}`.
29 | - The need to move code from `construct()` to `methods()`, `handlers()`, etc.
30 |
31 | Since the linter is very tolerant it is a good candidate for use with nearly all A2 projects that are migrating to a newer version.
32 |
33 | #### Automated upgrading
34 |
35 | This module can also carry out some upgrades automatically. While this is a great feature, keep in mind that not every module and project is a good candidate for automated code upgrading as there are an infinite number of ways projects can be structured. The upgrade feature works best on projects that adhere very closely to the coding style of the official A2 sample projects and documentation.
36 |
37 | There are also many needed changes that the upgrade command cannot handle on its own. So a successful upgrade will always involve reviewing the output of the linting feature, as well as the Apostrophe documentation.
38 |
39 | Where possible, the code upgrader will convert Apostrophe 2 codebases for installable modules *and* full A2 websites so they are *mostly ready* to run a newer version of Apostrophe. This includes:
40 | - Moving modules from `lib/modules` to the `modules` directory.
41 | - Renaming most project-level Apostrophe core module customization directories to the newer equivalents.
42 | - Converting field schemas, columns, and similarly structured features to the newer "cascade" configuration structure, if the existing module follows the structure of the official A2 example projects closely enough.
43 | - Converting utility methods such as `addHelper()`, `apiRoute()`, and others to newer module customization functions, again if the project closely follows the structure of the official A2 sample projects.
44 | - Moving code in `beforeConstruct`, `construct`, and `afterConstruct` that can't otherwise be converted into appropriate newer version module functions.
45 | - And more...
46 |
47 | ### What it doesn't do
48 |
49 | The primary thing to understand is that this tool is not likely to make the project codebase 100% ready to use with newer versions of Apostrophe all by itself. Its mission is to significantly *reduce* the manual work required to do so, and help you discover what you have to do next.
50 |
51 | *Some* of the things that you can expect to need to do manually include:
52 | - jQuery-powered widget players (not "lean mode") due to their structure and lack of jQuery in newer versions by default.
53 | - "Anonymous" area configuration in template files. These configurations must be moved into the proper module's `index.js` schema definition.
54 | - Some schema field and widget options due to the wide varation.
55 | - Image widgets used for multi-image slideshows, as the newer image widget only supports a single image.
56 | - All or nearly all updates to files pulled into modules via `require`.
57 |
58 | ## Installation
59 |
60 | To install the module:
61 |
62 | ```
63 | npm install -g @apostrophecms/code-upgrader
64 | ```
65 |
66 | The `apos-code-upgrader` command is now available in your command line shell.
67 |
68 | ## Project preparation
69 |
70 | While the linting features leave your project as-is, the upgrade features will change most files in the codebase. It is important to prepare for this by making sure the project has version control active and ready to support this process. First and foremost, **the codebase must have git version control active**. This tool will stop if it cannot find evidence of git.
71 |
72 | ### Recommended steps
73 |
74 | 1. In the terminal, make sure you are in your project root.
75 | 2. Confirm that `git status` is clean (no active changes).
76 | 3. Make a **new branch** for the upgrade work (e.g., `project-upgrade`). This will prevent any accidental problems from committing changes in the main branch.
77 |
78 | ## Linting the A2 codebase
79 |
80 | Use the command `apos-code-upgrader lint` to run a linter scan of the A2 code. This will print to the console every required change it can find. This will *not* actually change any code. It is an especially useful step after you run the upgrade process, but it can be useful before to understand what changes to expect.
81 |
82 | ## Automatically upgrading the A2 codebase
83 |
84 | 1. Type `apos-code-upgrader upgrade` in the project root to have the tool actually convert code to the newer versions expected structure and syntax where possible. There may be immediate messages printed to the console suggesting next steps.
85 | 2. Run the linter command, `apos-code-upgrader lint`, to see any remaining changes that are detected and require manual conversion.
86 | 3. Review the changes (before committing them) with `git status` and `git diff HEAD`. Even though files are moved and directories renamed, git will still be able to display line changes for most of them.
87 |
88 | Please note that you will definitely need to make manual changes to complete your upgrade.
89 |
90 | ### Options
91 |
92 | The `upgrade` command supports the following additional command line option flags:
93 |
94 | `--upgrade-required-files`
95 |
96 | The upgrade command has experimental support for inlining certain `require`-d files in order to discover methods, handlers, etc. inside those files as well. This generally will not work well unless the required files limit their interesting logic to an exported function, which `index.js` invokes with `(self, options)`. You can try out this experimental feature by adding the `--upgrade-required-files` flag.
97 |
98 | ### Reset the changes before committing
99 |
100 | If you want to undo all the changes made by the tool for any reason, run `apos-code-upgrader reset` in the project root. You must do this *before committing the changes*. This does a simple hard reset of your local branch with git and will let you start again after reviewing the changes if you desire.
101 |
102 | As a last resort you can always switch back to the main git branch and create a new upgrade branch to start over. You did switch off the main branch at the start, didn't you? 🤓
103 |
104 |
--------------------------------------------------------------------------------
/lib/linters.js:
--------------------------------------------------------------------------------
1 | const { stripIndent } = require('common-tags');
2 | const legacyModuleNameMap = require('./legacy-module-name-map');
3 |
4 | const js = {
5 | addHelpers: {
6 | match: /addHelpers\s*\(/g,
7 | message: 'Migrate this code to the new "helpers" module initialization function.',
8 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#helpers-self'
9 | },
10 | addHelperShortcut: {
11 | match: /addHelperShortcut\s*\(/g,
12 | message: 'Removed in A3. We suggest adding a module alias and namespacing your helper calls.',
13 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-options.html#alias'
14 | },
15 | construct: {
16 | match: /construct:?\s*\(/g,
17 | message: stripIndent`
18 | Removed in A3. Methods must move to the methods section, routes to various route
19 | sections, event handlers to the handlers section, etc. Other code executing at
20 | startup should move to init(self, options).
21 | `,
22 | url: 'https://v3.docs.apostrophecms.org/guide/upgrading.html#new-features'
23 | },
24 | beforeConstruct: {
25 | match: /beforeConstruct:?\s*\(/g,
26 | message: stripIndent`
27 | Removed in A3. Code that adjusts addFields, removeFields, etc. should move to the
28 | new fields section. Similar sections exists for columns and other items adjusted
29 | here in A2. beforeSuperClass(self, options) is also available but it is
30 | highly likely that you do not need it.
31 | `,
32 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#fields'
33 | },
34 | afterConstruct: {
35 | match: /afterConstruct:?\s*\(/g,
36 | message: stripIndent`
37 | Renamed to init() in A3. init() may be async and will be awaited.
38 | No callback will be passed.
39 | `
40 | },
41 | callback: {
42 | match: /callback|cb\)/g,
43 | message: stripIndent`
44 | A3 does not use callbacks. If you are using a callback here to interface
45 | with a third-party library that uses callbacks you should use
46 | util.promisify() to wrap that function, making it awaitable.
47 | `
48 | },
49 | asyncModule: {
50 | match: /async\./g,
51 | message: stripIndent`
52 | The use of the async npm module is not recommended in A3 which natively
53 | supports async/await patterns. If you need to iterate over data you can "await"
54 | inside a "for...of" loop.
55 | `
56 | },
57 | lifecycleMethods: {
58 | match: /self.(beforeInsert|beforeUpdate|beforeSave|afterInsert|afterUpdate|afterSave)/g,
59 | message: stripIndent`
60 | Piece types no longer have overrideable lifecycle methods in A3. Instead
61 | you should write a handler for the corresponding event in the
62 | handlers(self, options) section.
63 | `,
64 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#handlers-self'
65 | },
66 | selfOn: {
67 | match: /self\.on/g,
68 | message: stripIndent`
69 | Rather than calling self.on() directly to set up an event handler you should
70 | use the new handlers(self, options) section.
71 | `,
72 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#handlers-self'
73 | },
74 | name: {
75 | matchParsed(parsed) {
76 | return parsed.exports.name;
77 | },
78 | message: stripIndent`
79 | In A3, piece and widget types no longer have a "name" option. For piece types,
80 | you should rename this module and its directory to the old "name" setting and
81 | remove the setting. For widget types, you should rename the module and its
82 | directory to the old "name" setting with "-widget" appended and remove
83 | the setting.
84 | `,
85 | url: 'https://v3.docs.apostrophecms.org/guide/upgrading.html#breaking-changes'
86 | },
87 | addFields: {
88 | match: /addFields/g,
89 | message: stripIndent`
90 | The addFields option has been replaced by the "add" subproperty of the
91 | "fields" section. Array fields can have their own "fields" section.
92 | `,
93 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#fields'
94 | },
95 | removeFields: {
96 | match: /removeFields/g,
97 | message: stripIndent`
98 | The removeFields option has been replaced by the "remove" subproperty of the
99 | "fields" section. Array fields can have their own "fields" section.
100 | `,
101 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#fields'
102 | },
103 | arrangeFields: {
104 | match: /arrangeFields/g,
105 | message: stripIndent`
106 | The arrangeFields option has been replaced by the "group" subproperty of the
107 | "fields" section. Array fields can have their own "fields" section.
108 | `,
109 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#fields'
110 | },
111 | pushScriptForUsers: {
112 | match: /pushAsset/g,
113 | message: stripIndent`
114 | The pushAsset method is no longer used in A3. Frontend scripts can be placed
115 | in the ui/src/index.js file of the module or a file imported into it
116 | with the import statement. ui/src/index.js must export a function, which
117 | will be invoked in the order in which modules are activated.
118 |
119 | Frontend stylesheets can be placed in the ui/src/index.scss file of the module
120 | or a file imported into it with the @import statement.
121 |
122 | Backend scripts and stylesheets for the admin UI must be repackaged in Vue.
123 | `
124 | },
125 | methods: {
126 | match: /self\.\w+\s*=.*?(function|=>)/g,
127 | message: stripIndent`
128 | Method definitions should be moved to the new "methods" section of the module,
129 | unless you are extending an existing method with the old "super" pattern, in
130 | which case they should move to the new "extendMethods" section, with a
131 | "_super" argument at the beginning.
132 | `,
133 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#methods'
134 | },
135 | browserCall: {
136 | match: /apos\.push\.browserCall/g,
137 | message: stripIndent`
138 | The "apos.push.browserCall" mechanism does not exist in A3. See the
139 | new "enableBrowserData" method and implement or extend the "getBrowserData"
140 | method to make data available at page load time in the browser, or
141 | add data attributes to your markup, or use the REST API after page load time.
142 | `
143 | },
144 | reqCall: {
145 | match: /req\.browserCall/g,
146 | message: stripIndent`
147 | The "req.browserCall" mechanism does not exist in A3. See the
148 | new "enableBrowserData" method and implement or extend the "getBrowserData"
149 | method to make data available at page load time in the browser, or
150 | add data attributes to your markup, or use the REST API after page load time.
151 | `
152 | },
153 | tagsField: {
154 | match: /type:\s*["']tags[".]/g,
155 | message: stripIndent`
156 | There is no "tags" field type in A3. If you need tags for a particular
157 | piece type, add a piece type for that purpose and create a "relationship"
158 | field to connect them.
159 | `
160 | },
161 | tagsModule: {
162 | match: /apos\.tags|apostrophe-tags/g,
163 | message: stripIndent`
164 | There is no "apostrophe-tags" module in A3. If you need tags for a particular
165 | piece type, add a piece type for that purpose and create a "relationship"
166 | field to connect them.
167 | `
168 | },
169 | joinByOne: {
170 | match: /joinByOne/g,
171 | message: stripIndent`
172 | Apostrophe A3 has no "joinByOne" field type. Use the "relationship" field
173 | type instead and set both "required: true" and "max: 1". The loaded data will
174 | appear as an array with a single element.
175 | `
176 | },
177 | joinByOneReverse: {
178 | match: /joinByOneReverse/g,
179 | message: stripIndent`
180 | Apostrophe A3 has no "joinByOneReverse" field type. Change your "joinByOne"
181 | field to a "relationship" field as noted elsewhere, then change "joinByOneReverse"
182 | to "relationshipReverse".
183 | `
184 | },
185 | joinByArray: {
186 | match: /joinByArray/g,
187 | message: stripIndent`
188 | In A3 the "joinByArray" field type has been renamed to "relationship".
189 | `
190 | },
191 | joinByArrayReverse: {
192 | match: /joinByArrayReverse/g,
193 | message: stripIndent`
194 | Apostrophe A3 has no "joinByArrayReverse" field type. Change your "joinByArray"
195 | field to a "relationship" field as noted elsewhere, then change "joinByArrayReverse"
196 | to "relationshipReverse".
197 | `
198 | },
199 | tasks: {
200 | match: /apos\.tasks\.add|self\.addTask/g,
201 | message: stripIndent`
202 | Apostrophe tasks have moved to the new "tasks" section.
203 | `,
204 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#tasks'
205 | },
206 | widgetOutput: {
207 | match: /self\.output\s*=/g,
208 | message: stripIndent`
209 | Overriding the "output" method of a widget module is usually unnecessary
210 | as it simply renders the template. If you must override it be aware
211 | that the arguments have changed to:
212 |
213 | "req, widget, options, _with"
214 |
215 | Also, the output method is now async.
216 | `
217 | },
218 | filterOptionsForDataAttribute: {
219 | match: /filterOptionsForDataAttribute/g,
220 | message: stripIndent`
221 | filterOptionsForDataAttribute is not needed in A3, as A3 does not add any data
222 | attributes to a widget by default, except when editing. Your widget templates
223 | are responsible for making any needed data available to widget players via data
224 | attributes.
225 | `
226 | },
227 | addFieldType: {
228 | match: /(schema|self)\.addFieldType/g,
229 | message: stripIndent`
230 | Be aware that addFieldType has changed in A3. There is just one "convert"
231 | method per field type, which should accept either a string representation
232 | for import purposes (CSV, etc) and may also accept a different representation
233 | for form submissions where appropriate. "convert" may be async and now receives
234 | these arguments, typically copying from "data[field.name]" to
235 | "destination[field.name]" after sanitization:
236 |
237 | "req, field, data, destination"
238 | `
239 | },
240 | joinByArraySchema: {
241 | match: /(joinByArray|relationship)[\s\S]{1,1000}schema["']?:/,
242 | message: stripIndent`
243 | It looks like you may have a joinByArray or relationship field
244 | with a schema. In addition to changing "joinByArray" to "relationship",
245 | you will need to change "schema" to a "fields" property with
246 | an "add" subproperty, structured the same way as the "fields"
247 | section of a module.
248 | `
249 | },
250 | arraySchema: {
251 | match: /(["']array["'])[\s\S]{1,1000}schema["']?:/,
252 | message: stripIndent`
253 | It looks like you may have an array field with a schema. You will need to
254 | change "schema" to a "fields" property with an "add" subproperty, structured
255 | the same way as the "fields" section of a module.
256 | `
257 | },
258 | route: {
259 | match: /self\.route\s*\(/g,
260 | message: stripIndent`
261 | The "route" method has been replaced by the new "routes" section of index.js.
262 | Also consider "apiRoutes" or "renderRoutes" for the simplest solution depending
263 | on your needs.
264 | `,
265 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#routes-self'
266 | },
267 | apiRoute: {
268 | match: /self\.apiRoute\s*\(/g,
269 | message: stripIndent`
270 | The "apiRoute" method has been replaced by the new "apiRoutes" section of index.js.
271 | Note that in A3 API routes may be async and simply return an object or throw an exception,
272 | they do not invoke next().
273 | `,
274 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#apiroutes-self'
275 | },
276 | renderRoute: {
277 | match: /self\.renderRoute\s*\(/g,
278 | message: stripIndent`
279 | The "renderRoute" method has been replaced by the new "renderRoutes" section of index.js.
280 | Note that in A3 renderRoutes may be async and simply return a data object for the
281 | template, they do not invoke next().
282 | `,
283 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#renderroutes-self'
284 | },
285 | htmlRoute: {
286 | match: /self\.htmlRoute\s*\(/g,
287 | message: stripIndent`
288 | The "htmlRoute" method has been replaced by the new "apiRoutes" section of index.js.
289 | Note that in A3 apiRoutes may be async and if they return a string, it is treated
290 | as an HTML response. They do not call next(). Also consider "renderRoutes".
291 | `,
292 | url: 'https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#apiroutes-self'
293 | }
294 | };
295 |
296 | const html = {
297 | area: {
298 | match: /apos\.area\s*\(/g,
299 | message: stripIndent`
300 | A3 uses the new {% area doc, 'areaName' %} syntax. Note the use of
301 | {% ... %}, not {{ ... }}. Every area must be configured in the schema
302 | of a piece type, page type or widget. An object of context options can
303 | be passed after the "with" keyword, with one property for each widget
304 | type, and appear as "data.contextOptions" in the widget.html template.
305 | `,
306 | url: 'https://v3.docs.apostrophecms.org/guide/upgrading.html#areas-and-pages'
307 | },
308 | singleton: {
309 | match: /apos\.singleton/g,
310 | message: stripIndent`
311 | A3 no longer has a separate "singleton" field type. Use the new
312 | {% area doc, 'areaName' %} syntax. Configure the area in the schema
313 | of a piece type, page type or widget with "max" set to 1.
314 | `,
315 | url: 'https://v3.docs.apostrophecms.org/guide/upgrading.html#areas-and-pages'
316 | },
317 | macro: {
318 | match: /{%\s*macro/g,
319 | message: stripIndent`
320 | In A3, Nunjucks macros cannot contain areas, but fragments can.
321 | Fragments are very similar to macros and can do the same tasks.
322 | It is recommended that you change all of your macros to fragments.
323 | Be aware that "with context" is not supported by fragments.
324 | See the documentation for details on how best to do this.
325 | `,
326 | url: 'https://v3.docs.apostrophecms.org/guide/fragments.html'
327 | }
328 | };
329 |
330 | // A special case, with support for matchName and matchFilename
331 |
332 | const lintModule = {
333 | widgets: {
334 | matchName: /-widgets$/,
335 | message: stripIndent`
336 | In A3, widget module names must end in -widget, preceded by what
337 | would have been set for the "name" option in A2. The "name" option,
338 | if still present, should be removed.
339 | `
340 | },
341 | libModules: {
342 | matchFilename: /lib\/modules/,
343 | message: stripIndent`
344 | In A3, the "lib/modules" folder must be renamed "modules" in order
345 | to be recognized. You may choose to keep utility code in "lib",
346 | only Apostrophe modules need to be moved.
347 | `,
348 | once: true
349 | },
350 | legacyModules: {
351 | matchName: name => legacyModuleNameMap[name],
352 | message: ({ name }) => stripIndent`
353 | In A3, the ${name} module has been renamed ${legacyModuleNameMap[name]}.
354 | `
355 | }
356 | };
357 |
358 | module.exports = {
359 | js,
360 | html,
361 | module: lintModule,
362 | // Apostrophe also supports .njk files
363 | njk: html
364 | };
365 |
--------------------------------------------------------------------------------
/lib/upgrader.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const acorn = require('acorn');
4 | const escodegen = require('escodegen');
5 | const cp = require('child_process');
6 | const { stripIndent } = require('common-tags');
7 | // Node 14 is still supported
8 | const replaceAll = require('string.prototype.replaceall');
9 |
10 | const fail = require('./fail.js');
11 | const get = require('./get.js');
12 | const legacyModuleNameMap = require('./legacy-module-name-map.js');
13 | const { globByExtension, globByPattern } = require('./glob-by-extension.js');
14 |
15 | // This is a random identifier not used anywhere else
16 | const blankLineMarker = '// X0k7FEu5a6!bC6mV';
17 | const renamedModulesMap = {};
18 |
19 | module.exports = ({ argv }) => {
20 | try {
21 | cp.execSync('git status', { encoding: 'utf8' });
22 | } catch (e) {
23 | fail(stripIndent`
24 | This project does not appear to be using git. For your protection and to simplify
25 | certain operations this tool can only be used in a git repository.
26 | `);
27 | }
28 |
29 | replace({ argv });
30 | refactor({ argv });
31 | console.log(stripIndent`
32 | The upgrade task is complete, but it is certain that you will need
33 | to make additional adjustments and changes.
34 |
35 | To review the changes made by the task, type "git status" to see
36 | the changes to the file layout, and "git diff HEAD" to see the changes
37 | made to individual files.
38 |
39 | After that you should run "apos-code-upgrader lint" to view additional
40 | code upgrade concerns requiring manual attention.
41 | `);
42 |
43 | };
44 |
45 | function replaceOccurences(files, replaces) {
46 | for (const file of files) {
47 | const original = fs.readFileSync(file, { encoding: 'utf8' });
48 | let transformed = original;
49 | for (const [ pattern, replacement ] of replaces) {
50 | transformed = replaceAll(transformed, pattern, replacement);
51 | }
52 | if (original !== transformed) {
53 | fs.writeFileSync(file, transformed);
54 | }
55 | }
56 | }
57 |
58 | // Simple transformations involving global replace
59 | function replace({ argv }) {
60 | const files = [ ...globByExtension('js'), ...globByExtension('html') ];
61 | const replaces = [
62 | // Address common aliases that have changed in A3
63 | [ 'apos.tasks', 'apos.task' ],
64 | [ 'apos.locks', 'apos.lock' ],
65 | [ 'apos.versions', 'apos.version' ],
66 | [ 'apos.images', 'apos.image' ],
67 | [ 'apos.groups', 'apos.group' ],
68 | [ 'apos.permissions', 'apos.permission' ],
69 | [ 'apos.docs', 'apos.doc' ],
70 | [ 'apos.utils', 'apos.util' ],
71 | [ 'apos.attachments', 'apos.attachment' ],
72 | [ 'apos.caches', 'apos.cache' ],
73 | [ 'apos.files', 'apos.file' ],
74 | [ 'apos.users', 'apos.user' ],
75 | [ 'apos.urls', 'apos.url' ],
76 | [ 'apos.migrations', 'apos.migration' ],
77 | [ 'apos.templates', 'apos.template' ],
78 | [ 'apos.videoFields', 'apos.videoField' ],
79 | [ 'apos.pages', 'apos.page' ],
80 | // No blanket replace for areas because we don't want to confuse the
81 | // separate issue of linting the "apos.area" helper by renaming "apos.areas"
82 | // to that. These are the individual likely offenders
83 | [ 'apos.areas.isEmpty', 'apos.area.isEmpty' ],
84 | [ 'apos.areas.getWidgetManager', 'apos.area.getWidgetManager' ],
85 | [ 'apos.areas.richText', 'apos.area.richText' ],
86 | [ 'apos.areas.plaintext', 'apos.area.plaintext' ],
87 | [ 'apos.areas.walk', 'apos.area.walk' ]
88 | ];
89 |
90 | replaceOccurences(files, replaces);
91 | }
92 |
93 | function renameModulesDeclarations(isMultisite) {
94 | const files = [
95 | 'app.js',
96 | ...isMultisite
97 | ? [
98 | 'dashboard/index.js',
99 | 'sites/index.js',
100 | ...globByPattern('dashboard/lib/theme-*.js'),
101 | ...globByPattern('sites/lib/theme-*.js')
102 | ]
103 | : []
104 | ];
105 |
106 | const renamedReplaces = Object.entries(renamedModulesMap)
107 | .map(([ old, newName ]) => {
108 | const oldName = old.replace('dashboard/', '').replace('sites/', '');
109 |
110 | return [ oldName, newName ];
111 | }, []).filter(([ oldName, newName ]) => !legacyModuleNameMap[oldName]);
112 |
113 | const replaces = [ ...renamedReplaces, ...Object.entries(legacyModuleNameMap) ];
114 |
115 | replaceOccurences(files, replaces);
116 | }
117 |
118 | // Heavy duty transformations involving an actual javascript parser
119 | function refactor({ argv }) {
120 | let projectHelpersNeeded = false;
121 | if (isSingleSiteProject()) {
122 | manageSingleProject();
123 | } else if (isMultiSiteProject()) {
124 | manageMultiSiteProject();
125 | } else if (isSingleNpmModule()) {
126 | processStandaloneModule();
127 | } else if (isMoogBundle()) {
128 | if (!fs.existsSync('./lib/modules')) {
129 | // TODO fully parse out the directory setting in index.js, but
130 | // this is a strong norm
131 | fail('Currently moog bundles are only supported if they have a lib/modules directory.');
132 | }
133 | manageSingleProject();
134 | processStandaloneModule();
135 | console.log(stripIndent`
136 | ⚠️ "lib/modules" has been renamed to modules, but you will need to
137 | manually change "lib/modules" to modules in the index.js file of
138 | the bundle.
139 | `);
140 | } else {
141 | fail(stripIndent`
142 | The current directory does not look like a single-site apostrophe project,
143 | an Apostrophe module packaged as an npm module, or a bundle of Apostrophe modules
144 | packaged as an npm module. Not sure what to do.
145 | `);
146 | }
147 | injectHelpersIfNeeded();
148 |
149 | function manageSingleProject() {
150 | // Only touch directories, don't mess about with regular files in lib/modules
151 | // or symlinks in lib/modules
152 | let moduleNames = fs.readdirSync('lib/modules')
153 | .filter(moduleName => fs.lstatSync(`lib/modules/${moduleName}`).isDirectory());
154 | moduleNames.forEach(name => {
155 | rename(`lib/modules/${name}`, `modules/${name}`);
156 | });
157 | try {
158 | fs.rmdirSync('lib/modules');
159 | } catch (e) {
160 | removeModuleFolderError(e);
161 | }
162 | moduleNames = fs.readdirSync('modules');
163 | moduleNames.forEach(moduleName => {
164 | processModuleInFolder('modules', moduleName);
165 | });
166 |
167 | renameModulesDeclarations();
168 | }
169 |
170 | function manageMultiSiteProject() {
171 | const modulesLocations = [ 'dashboard', 'sites' ];
172 |
173 | modulesLocations.forEach((folderName) => {
174 | const moduleNames = fs.readdirSync(`${folderName}/lib/modules`)
175 | .filter((moduleName) => fs.lstatSync(`${folderName}/lib/modules/${moduleName}`).isDirectory());
176 |
177 | moduleNames.forEach(name => {
178 | rename(`${folderName}/lib/modules/${name}`, `${folderName}/modules/${name}`);
179 | });
180 | });
181 |
182 | try {
183 | modulesLocations.forEach((location) => {
184 | fs.rmdirSync((`${location}/lib/modules`));
185 | });
186 | } catch (e) {
187 | removeModuleFolderError(e);
188 | }
189 |
190 | modulesLocations.forEach((location) => {
191 | const modules = fs.readdirSync(`${location}/modules`);
192 |
193 | modules.forEach((moduleName) => {
194 | processModuleInFolder(`${location}/modules`, moduleName, `${location}/`);
195 | });
196 | });
197 |
198 | renameModulesDeclarations(true);
199 | }
200 |
201 | function removeModuleFolderError(e) {
202 | if (e.code === 'ENOTEMPTY') {
203 | console.error(stripIndent`
204 | "lib/modules" is not empty after moving modules to "modules". You probably
205 | have files that are not Apostrophe modules in that folder. Please
206 | move those files to a more appropriate location, like "lib", then
207 | remove "lib/modules" yourself.
208 | `);
209 | } else {
210 | throw e;
211 | }
212 | }
213 |
214 | function processModuleInFolder(folder, moduleName, distinguishFolder = '') {
215 | const renamedModule = renamedModulesMap[moduleName] || moduleName;
216 |
217 | let moduleFilename = `${folder}/${renamedModule}/index.js`;
218 | if (!fs.existsSync(moduleFilename)) {
219 | // Not all project level modules have an index.js file, but
220 | // always create at least a minimal index.js file to
221 | // avoid problems if anything must be hoisted there
222 | // from a template, etc.
223 | fs.writeFileSync(moduleFilename, 'module.exports = {};');
224 | }
225 | const newModuleName = filterModuleName(renamedModule);
226 | if (newModuleName !== renamedModule) {
227 | moduleFilename = renameModule(renamedModule, newModuleName);
228 | }
229 | return processModule(renamedModule, moduleFilename, { renameModule });
230 |
231 | function renameModule(renamedModule, newModuleName) {
232 | const newModuleFilename = moduleFilename.replace(`${renamedModule}/index.js`, `${newModuleName}/index.js`);
233 | // Rename the module folder, the index.js part doesn't change
234 | rename(path.dirname(moduleFilename), path.dirname(newModuleFilename));
235 | renamedModulesMap[`${distinguishFolder}${renamedModule}`] = newModuleName;
236 | return newModuleFilename;
237 | }
238 | }
239 |
240 | function processStandaloneModule() {
241 | const packageInfo = JSON.parse(fs.readFileSync('package.json', 'utf8'));
242 | let main = packageInfo.main || 'index.js';
243 | if (!main.endsWith('.js')) {
244 | main += '.js';
245 | }
246 | const moduleName = packageInfo.name;
247 | processModule(moduleName, main, {
248 | renameModule(moduleName, newModuleName) {
249 | if (packageInfo.name.includes('@')) {
250 | packageInfo.name = packageInfo.name.replace(`/${moduleName}`, newModuleName);
251 | } else {
252 | packageInfo.name = newModuleName;
253 | }
254 | console.error(`This repository should be renamed ${newModuleName}`);
255 | return main;
256 | }
257 | });
258 | }
259 |
260 | function processModule(moduleName, moduleFilename, { renameModule }) {
261 |
262 | const code = protectBlankLines(require('fs').readFileSync(moduleFilename, 'utf8'));
263 | let helpers;
264 | const comments = [];
265 | const tokens = [];
266 | let parsed = acorn.parse(code, {
267 | ranges: true,
268 | locations: true,
269 | onComment: comments,
270 | onToken: tokens,
271 | ecmaVersion: 2020
272 | });
273 | parsed = escodegen.attachComments(parsed, comments, tokens);
274 | const prologue = [];
275 | let methods = [];
276 | const earlyInits = [];
277 | let lateInits = [];
278 | let adjusts = [];
279 | let helpersNeeded = false;
280 | const options = [];
281 | const newModuleBodyProperties = [];
282 | const routes = {};
283 | const superCaptures = {};
284 | const moveMethodsToHandlers = [];
285 | const specials = {
286 | extend: true,
287 | improve: true,
288 | // A "special" because it already works exactly the
289 | // way we want it to in 2.x, i.e. leave it alone please
290 | customTags: true,
291 | moogBundle: 'bundle'
292 | };
293 | const importedPaths = [];
294 |
295 | const specialsFound = {};
296 |
297 | let moduleBody;
298 | const handlers = {};
299 |
300 | parsed.body.forEach(statement => {
301 | if (
302 | (get(statement, 'expression.left.object.name') === 'module') &&
303 | (get(statement, 'expression.left.property.name') === 'exports')
304 | ) {
305 | const right = get(statement, 'expression.right');
306 | if (get(right, 'type') !== 'ObjectExpression') {
307 | return null;
308 | }
309 | moduleBody = right;
310 | get(right, 'properties').forEach(property => {
311 | const name = get(property, 'key.name');
312 | if (name === 'construct') {
313 | const value = get(property, 'value.body');
314 | if (get(value, 'type') === 'BlockStatement') {
315 | parseConstruct(parsed, value.body);
316 | }
317 | } else if (name === 'beforeConstruct') {
318 | const value = get(property, 'value.body');
319 | if (get(value, 'type') === 'BlockStatement') {
320 | parseBeforeConstruct(value.body);
321 | }
322 | } else if (name === 'afterConstruct') {
323 | const value = get(property, 'value.body');
324 | if (get(value, 'type') === 'BlockStatement') {
325 | parseAfterConstruct(value.body);
326 | }
327 | } else if (specials[name]) {
328 | const special = (specials[name] === true) ? name : specials[name];
329 | specialsFound[special] = get(property, 'value');
330 | } else if (name === 'addFields') {
331 | handleFieldsOption('add', get(property, 'value'));
332 | } else if (name === 'arrangeFields') {
333 | handleFieldsOption('group', get(property, 'value'));
334 | } else if (name === 'removeFields') {
335 | const value = get(property, 'value');
336 | // Not like the others
337 | const fields = ensureFields();
338 | const remove = {
339 | type: 'Property',
340 | key: {
341 | type: 'Identifier',
342 | name: 'remove'
343 | },
344 | value
345 | };
346 | fields.value.properties.push(remove);
347 | } else {
348 | options[name] = get(property, 'value');
349 | }
350 | });
351 | }
352 | });
353 |
354 | // TODO also support detection of modules that extend a subclass of pieces,
355 | // but this is nontrivial and relatively uncommon in projects being migrated
356 | if (specialsFound.extend && specialsFound.extend.value === 'apostrophe-pieces') {
357 | if (options.name.value && (options.name.value !== moduleName)) {
358 | moduleFilename = renameModule(moduleName, options.name.value);
359 | moduleName = options.name.value;
360 | }
361 | }
362 |
363 | if (!moduleBody) {
364 | console.error(`⚠️ The module ${moduleName} has no module.export statement, ignoring it`);
365 | return;
366 | }
367 |
368 | parsed.body = prologue.concat(parsed.body);
369 |
370 | moduleBody.properties = [];
371 |
372 | Object.keys(specialsFound).forEach(special => {
373 | moduleBody.properties.push({
374 | type: 'Property',
375 | key: {
376 | type: 'Identifier',
377 | name: special
378 | },
379 | value: specialsFound[special]
380 | });
381 | });
382 |
383 | if (Object.keys(options).length) {
384 | moduleBody.properties.push({
385 | type: 'Property',
386 | key: {
387 | type: 'Identifier',
388 | name: 'options'
389 | },
390 | value: {
391 | type: 'ObjectExpression',
392 | properties: Object.keys(options).map(key => ({
393 | type: 'Property',
394 | key: {
395 | type: 'Identifier',
396 | name: key
397 | },
398 | value: options[key]
399 | }))
400 | }
401 | });
402 | }
403 |
404 | let inits = earlyInits.concat(lateInits);
405 |
406 | inits = inits.filter(function(init) {
407 | if (route('apiRoute', init)) {
408 | return false;
409 | } else if (route('renderRoute', init)) {
410 | return false;
411 | } else if (route('htmlRoute', init)) {
412 | return false;
413 | } else if (route('route', init)) {
414 | return false;
415 | } else if (addHelpers(init)) {
416 | return false;
417 | } else if (onEvent(init)) {
418 | return false;
419 | } else if (superCapture(init)) {
420 | return false;
421 | } else {
422 | return true;
423 | }
424 | });
425 |
426 | if (adjusts.length) {
427 | moduleBody.properties.push({
428 | type: 'Property',
429 | key: {
430 | type: 'Identifier',
431 | name: 'beforeSuperClass'
432 | },
433 | value: {
434 | type: 'FunctionExpression',
435 | params: [
436 | {
437 | type: 'Identifier',
438 | name: 'self'
439 | },
440 | {
441 | type: 'Identifier',
442 | name: 'options'
443 | }
444 | ],
445 | body: {
446 | type: 'BlockStatement',
447 | body: adjusts
448 | }
449 | },
450 | method: true
451 | });
452 | }
453 |
454 | if (inits.length) {
455 | moduleBody.properties.push({
456 | type: 'Property',
457 | key: {
458 | type: 'Identifier',
459 | name: 'init'
460 | },
461 | value: {
462 | type: 'FunctionExpression',
463 | params: [
464 | {
465 | type: 'Identifier',
466 | name: 'self'
467 | },
468 | {
469 | type: 'Identifier',
470 | name: 'options'
471 | }
472 | ],
473 | body: {
474 | type: 'BlockStatement',
475 | body: inits
476 | }
477 | },
478 | method: true,
479 | async: true
480 | });
481 | }
482 |
483 | Object.keys(routes).forEach(type => {
484 | moduleBody.properties.push({
485 | type: 'Property',
486 | key: {
487 | type: 'Identifier',
488 | name: type + 's'
489 | },
490 | value: {
491 | type: 'FunctionExpression',
492 | params: [
493 | {
494 | type: 'Identifier',
495 | name: 'self'
496 | },
497 | {
498 | type: 'Identifier',
499 | name: 'options'
500 | }
501 | ],
502 | body: {
503 | type: 'BlockStatement',
504 | body: [
505 | {
506 | type: 'ReturnStatement',
507 | argument: {
508 | type: 'ObjectExpression',
509 | properties: Object.keys(routes[type]).map(httpMethod => {
510 | return {
511 | type: 'Property',
512 | key: {
513 | type: 'Identifier',
514 | name: httpMethod
515 | },
516 | value: {
517 | type: 'ObjectExpression',
518 | properties: Object.keys(routes[type][httpMethod]).map(name => {
519 | const fns = routes[type][httpMethod][name];
520 | return {
521 | type: 'Property',
522 | key: {
523 | type: 'Identifier',
524 | name: name
525 | },
526 | leadingComments: fns[0].comments,
527 | value: middlewareAndRouteFunction(fns),
528 | method: (fns.length === 1)
529 | };
530 | })
531 | }
532 | };
533 | })
534 | }
535 | }
536 | ]
537 | }
538 | },
539 | method: true
540 | });
541 |
542 | });
543 |
544 | for (const item of moveMethodsToHandlers) {
545 | const method = methods.find(method => method.name === item[1]);
546 | handlers[item[0]][item[1]] = method.statement.expression.right;
547 | // handlers[item[0]][item[1]].comments = method.comments;
548 | // console.log(handlers[item[0]][item[1]].comments );
549 | methods = methods.filter(method => method.name !== item[1]);
550 | }
551 |
552 | const extendMethods = methods.filter(method => superCaptures[method.name]);
553 | methods = methods.filter(method => !superCaptures[method.name]);
554 |
555 | if (Object.keys(handlers).length) {
556 | moduleBody.properties.push({
557 | type: 'Property',
558 | key: {
559 | type: 'Identifier',
560 | name: 'handlers'
561 | },
562 | value: {
563 | type: 'FunctionExpression',
564 | params: [
565 | {
566 | type: 'Identifier',
567 | name: 'self'
568 | },
569 | {
570 | type: 'Identifier',
571 | name: 'options'
572 | }
573 | ],
574 | body: {
575 | type: 'BlockStatement',
576 | body: [
577 | {
578 | type: 'ReturnStatement',
579 | argument: {
580 | type: 'ObjectExpression',
581 | properties: Object.keys(handlers).map(eventName => {
582 | return {
583 | type: 'Property',
584 | key: {
585 | type: 'Literal',
586 | value: eventName
587 | },
588 | value: {
589 | type: 'ObjectExpression',
590 | properties: Object.keys(handlers[eventName]).map(name => ({
591 | type: 'Property',
592 | key: {
593 | type: 'Identifier',
594 | name: name
595 | },
596 | value: handlers[eventName][name],
597 | // leadingComments: handlers[eventName][name].comments,
598 | method: true
599 | }))
600 | }
601 | };
602 | })
603 | }
604 | }
605 | ]
606 | }
607 | },
608 | method: true
609 | });
610 | }
611 |
612 | moduleBody.properties = [ ...moduleBody.properties, ...newModuleBodyProperties ];
613 |
614 | outputMethods('methods', methods);
615 | outputMethods('extendMethods', extendMethods);
616 | outputHelpers(helpers);
617 |
618 | const required = {};
619 |
620 | if (argv['upgrade-required-files']) {
621 | parsed.body = parsed.body.filter(expression => {
622 | if (expression.type !== 'VariableDeclaration') {
623 | return true;
624 | }
625 | const declaration = expression && expression.declarations && expression.declarations[0];
626 | if (!declaration) {
627 | return true;
628 | }
629 | if (declaration.type !== 'VariableDeclarator') {
630 | return true;
631 | }
632 | if (get(declaration, 'init.type') !== 'CallExpression') {
633 | return true;
634 | }
635 | if (get(declaration, 'init.callee.name') !== 'require') {
636 | return true;
637 | }
638 | let varName;
639 | if (get(declaration, 'id.type') === 'ObjectPattern') {
640 | // const { foo, bar } = require('baz')
641 | varName = get(declaration, 'id.properties').map(property => get(property, 'key.name')).join(':');
642 | } else {
643 | // const bar = require('baz')
644 | varName = get(declaration, 'id.name');
645 | }
646 | const args = get(declaration, 'init.arguments');
647 | const arg = args && (args.length === 1) && args[0];
648 | if (!arg) {
649 | return true;
650 | }
651 | if (required[varName]) {
652 | // Duplicate stomped
653 | return false;
654 | }
655 | required[varName] = true;
656 | return true;
657 | });
658 | }
659 |
660 | // if we're going to crash, do it before we start overwriting or
661 | // removing any files
662 |
663 | const generated = restoreBlankLines(escodegen.generate(parsed, {
664 | format: {
665 | indent: {
666 | style: ' ',
667 | base: 0,
668 | adjustMultilineComment: false
669 | },
670 | newline: '\n',
671 | space: ' ',
672 | json: false,
673 | renumber: false,
674 | hexadecimal: false,
675 | quotes: 'single',
676 | escapeless: false,
677 | compact: false,
678 | parentheses: true,
679 | semicolons: true,
680 | safeConcatenation: false
681 | },
682 | comment: true
683 | }));
684 |
685 | fs.writeFileSync(moduleFilename, generated);
686 |
687 | importedPaths.map(fs.unlinkSync);
688 |
689 | function parseConstruct(parsed, body) {
690 | body.forEach(statement => {
691 | if (statement.type === 'ExpressionStatement') {
692 | if (statement.expression.type === 'AssignmentExpression') {
693 | const methodName = get(statement, 'expression.left.property.name');
694 | const fn = get(statement, 'expression.right');
695 | if (fn.type === 'FunctionExpression') {
696 | methods.push({
697 | name: methodName,
698 | statement: statement,
699 | leadingComments: statement.leadingComments
700 | });
701 | return;
702 | }
703 | } else if (options['upgrade-required-files'] && (statement.expression.type === 'CallExpression') && (get(statement, 'expression.callee.callee.name') === 'require')) {
704 | const args = get(statement, 'expression.arguments');
705 | if ((args.length === 2) && (args[0].name === 'self') &&
706 | (args[1].name === 'options')) {
707 | const requirePath = get(statement, 'expression.callee.arguments.0.value');
708 | // recurse into path
709 | let fsPath = path.resolve(path.dirname(moduleFilename), requirePath);
710 | if (!fsPath.match(/\.js$/)) {
711 | fsPath += '.js';
712 | }
713 | importedPaths.push(fsPath);
714 | const code = require('fs').readFileSync(fsPath, 'utf8');
715 | const comments = [];
716 | const tokens = [];
717 | let parsed = acorn.parse(code, {
718 | locations: true,
719 | ranges: true,
720 | onToken: tokens,
721 | onComment: comments
722 | });
723 | parsed = escodegen.attachComments(parsed, comments, tokens);
724 | parsed.body.forEach(statement => {
725 | if (
726 | (get(statement, 'expression.left.object.name') === 'module') &&
727 | (get(statement, 'expression.left.property.name') === 'exports')
728 | ) {
729 | const right = get(statement, 'expression.right');
730 | if (get(right, 'type') !== 'FunctionExpression') {
731 | return null;
732 | }
733 | if (right.body && right.body.body) {
734 | parseConstruct(parsed, right.body.body);
735 | }
736 | } else {
737 | prologue.push(statement);
738 | }
739 | });
740 | return;
741 | }
742 | }
743 | }
744 | earlyInits.push(statement);
745 | });
746 | }
747 |
748 | function parseBeforeConstruct(body) {
749 | adjusts = body;
750 | }
751 |
752 | function parseAfterConstruct(body) {
753 | lateInits = lateInits.concat(body);
754 | }
755 |
756 | function middlewareAndRouteFunction(fns) {
757 | if (fns.length === 1) {
758 | // Methods are not arrow functions
759 | fns[0].type = 'FunctionExpression';
760 | return fns[0];
761 | } else {
762 | return {
763 | type: 'ArrayExpression',
764 | elements: fns
765 | };
766 | }
767 | }
768 |
769 | function camelName(s) {
770 | // Keep in sync with client side version
771 | let i;
772 | let n = '';
773 | let nextUp = false;
774 | for (i = 0; (i < s.length); i++) {
775 | const c = s.charAt(i);
776 | // If the next character is already uppercase, preserve that, unless
777 | // it is the first character
778 | if ((i > 0) && c.match(/[A-Z]/)) {
779 | nextUp = true;
780 | }
781 | if (c.match(/[A-Za-z0-9]/)) {
782 | if (nextUp) {
783 | n += c.toUpperCase();
784 | nextUp = false;
785 | } else {
786 | n += c.toLowerCase();
787 | }
788 | } else {
789 | nextUp = true;
790 | }
791 | }
792 | return n;
793 | };
794 |
795 | // Recursively replace an identifier such as "superOldMethodName" with an
796 | // identifier such as "_super" throughout "context", even if nested etc.
797 |
798 | function replaceIdentifier(context, oldId, newId) {
799 | Object.keys(context).forEach(key => {
800 | const value = context[key];
801 | if (value && (value.type === 'Identifier') && (value.name === oldId)) {
802 | value.name = newId;
803 | }
804 | if (value && ((typeof value) === 'object')) {
805 | replaceIdentifier(value, oldId, newId);
806 | }
807 | });
808 | }
809 |
810 | function route(type, init) {
811 | if ((get(init, 'type') === 'ExpressionStatement') && (get(init, 'expression.type') === 'CallExpression') && (get(init, 'expression.callee.type') === 'MemberExpression') && (get(init, 'expression.callee.object.name') === 'self') && (get(init, 'expression.callee.property.name') === type)) {
812 | const args = get(init, 'expression.arguments');
813 | if (!args) {
814 | return false;
815 | }
816 | const method = get(args[0], 'value');
817 | const name = camelName(get(args[1], 'value'));
818 | if (!(method && name)) {
819 | return false;
820 | }
821 | const fns = args.slice(2);
822 | fns[0].leadingComments = init.leadingComments;
823 | routes[type] = routes[type] || {};
824 | routes[type][method] = routes[type][method] || {};
825 | routes[type][method][name] = fns;
826 | return true;
827 | } else {
828 | return false;
829 | }
830 | }
831 |
832 | function addHelpers(init) {
833 | if ((get(init, 'type') === 'ExpressionStatement') && (get(init, 'expression.type') === 'CallExpression') && (get(init, 'expression.callee.object.name') === 'self') && (get(init, 'expression.callee.property.name') === 'enableHelpers')) {
834 | const enableHelpers = methods.find(method => method.name === 'enableHelpers');
835 | if (enableHelpers) {
836 | const body = get(enableHelpers, 'statement.expression.right.body.body.0');
837 | if (addHelpers(body)) {
838 | methods = methods.filter(method => method.name !== 'enableHelpers');
839 | return true;
840 | }
841 | }
842 | return false;
843 | } else if ((get(init, 'type') === 'ExpressionStatement') && (get(init, 'expression.type') === 'CallExpression') && (get(init, 'expression.callee.object.name') === 'self') && (get(init, 'expression.callee.property.name') === 'addHelpers')) {
844 | const args = get(init, 'expression.arguments');
845 |
846 | if (args[0].type === 'ObjectExpression') {
847 | helpers = args[0];
848 | return true;
849 | } else if (
850 | (get(args[0], 'callee.object.name') === '_') &&
851 | (get(args[0], 'callee.property.name') === 'pick')
852 | ) {
853 | helpers = {
854 | type: 'ArrayExpression',
855 | elements: args[0].arguments.slice(1)
856 | };
857 | return true;
858 | }
859 | }
860 | }
861 |
862 | function onEvent(init) {
863 | if ((get(init, 'type') === 'ExpressionStatement') && (get(init, 'expression.type') === 'CallExpression') && (get(init, 'expression.callee.object.name') === 'self') && (get(init, 'expression.callee.property.name') === 'on')) {
864 | const args = get(init, 'expression.arguments');
865 | if ((args[0].type !== 'Literal') || (args[1].type !== 'Literal')) {
866 | return false;
867 | }
868 | if (!args[2]) {
869 | const fullEventName = args[0].value;
870 | const handlerName = args[1].value;
871 | handlers[fullEventName] = handlers[fullEventName] || {};
872 | moveMethodsToHandlers.push([ fullEventName, handlerName ]);
873 | return true;
874 | } else if ((args[2].type !== 'FunctionExpression') && (args[2].type !== 'ArrowFunctionExpression')) {
875 | return false;
876 | }
877 | args[2].type = 'FunctionExpression';
878 | const fullEventName = args[0].value;
879 | const handlerName = args[1].value;
880 | const handler = args[2];
881 | handlers[fullEventName] = handlers[fullEventName] || {};
882 | handlers[fullEventName][handlerName] = handler;
883 | return true;
884 | }
885 | }
886 |
887 | function superCapture(init) {
888 | if ((get(init, 'type') === 'VariableDeclaration') && get(init, 'declarations.0.id.name') && (get(init, 'declarations.0.id.name').match(/^super/))) {
889 | superCaptures[get(init, 'declarations.0.init.property.name')] = get(init, 'declarations.0.id.name');
890 | return true;
891 | }
892 | }
893 |
894 | // Thanks, anonymous! https://github.com/estools/escodegen/issues/277#issuecomment-363903537
895 |
896 | function protectBlankLines(code) {
897 | const lines = code.split('\n');
898 | const replacedLines = lines.map(line => {
899 | if (line.length === 0 || /^\s+$/.test(line)) {
900 | return blankLineMarker;
901 | }
902 | return line;
903 | });
904 | return replacedLines.join('\n').replace(/\n +\n/g, '\n\n');
905 | }
906 |
907 | function restoreBlankLines(code) {
908 | return code.split(blankLineMarker).join('');
909 | }
910 |
911 | function outputHelpers(helpers) {
912 | if (!helpers) {
913 | return;
914 | }
915 | if (helpers.type === 'ArrayExpression') {
916 | moduleBody.properties.push({
917 | type: 'Property',
918 | key: {
919 | type: 'Identifier',
920 | name: 'helpers'
921 | },
922 | value: helpers
923 | });
924 | } else {
925 | moduleBody.properties.push({
926 | type: 'Property',
927 | key: {
928 | type: 'Identifier',
929 | name: 'helpers'
930 | },
931 | value: {
932 | type: 'FunctionExpression',
933 | params: [
934 | {
935 | type: 'Identifier',
936 | name: 'self'
937 | },
938 | {
939 | type: 'Identifier',
940 | name: 'options'
941 | }
942 | ],
943 | body: {
944 | type: 'BlockStatement',
945 | body: [
946 | {
947 | type: 'ReturnStatement',
948 | argument: helpers
949 | }
950 | ]
951 | }
952 | },
953 | method: true
954 | });
955 | }
956 | }
957 |
958 | function outputMethods(category, methods) {
959 | if (methods.length) {
960 | moduleBody.properties.push({
961 | type: 'Property',
962 | key: {
963 | type: 'Identifier',
964 | name: category
965 | },
966 | value: {
967 | type: 'FunctionExpression',
968 | params: [
969 | {
970 | type: 'Identifier',
971 | name: 'self'
972 | },
973 | {
974 | type: 'Identifier',
975 | name: 'options'
976 | }
977 | ],
978 | body: {
979 | type: 'BlockStatement',
980 | body: [
981 | {
982 | type: 'ReturnStatement',
983 | argument: {
984 | type: 'ObjectExpression',
985 | properties: methods.map(method => {
986 | const fn = method.statement.expression.right;
987 | if (category === 'extendMethods') {
988 | fn.params = fn.params || [];
989 | fn.params.unshift({
990 | type: 'Identifier',
991 | name: '_super'
992 | });
993 | replaceIdentifier(fn, superCaptures[method.name], '_super');
994 | }
995 | return {
996 | type: 'Property',
997 | key: {
998 | type: 'Identifier',
999 | name: method.name
1000 | },
1001 | value: fn,
1002 | method: true,
1003 | leadingComments: method.leadingComments
1004 | };
1005 | })
1006 | }
1007 | }
1008 | ]
1009 | }
1010 | },
1011 | method: true
1012 | });
1013 | }
1014 | }
1015 |
1016 | function ensureFields() {
1017 | let fields = newModuleBodyProperties.find(property =>
1018 | (get(property, 'key.name') === 'fields') &&
1019 | (get(property, 'key.type') === 'Identifier')
1020 | );
1021 | if (!fields) {
1022 | fields = {
1023 | type: 'Property',
1024 | key: {
1025 | type: 'Identifier',
1026 | name: 'fields'
1027 | },
1028 | value: {
1029 | type: 'ObjectExpression',
1030 | properties: []
1031 | }
1032 | };
1033 | newModuleBodyProperties.push(fields);
1034 | }
1035 | return fields;
1036 | }
1037 |
1038 | function handleFieldsOption(subpropertyName, value) {
1039 | const fields = ensureFields();
1040 | const subproperty = {
1041 | type: 'Property',
1042 | key: {
1043 | type: 'Identifier',
1044 | name: subpropertyName
1045 | },
1046 | value: {
1047 | type: 'ObjectExpression',
1048 | properties: []
1049 | }
1050 | };
1051 | try {
1052 | if (value.type === 'ArrayExpression') {
1053 | for (const element of value.elements) {
1054 | if (element.type === 'ObjectExpression') {
1055 | const name = element.properties.find(property =>
1056 | (get(property, 'key.name') === 'name') &&
1057 | (get(property, 'key.type') === 'Identifier') &&
1058 | (!property.computed));
1059 | const literalOrIdentifier = nameToLiteralOrIdentifier(name);
1060 | const fieldProperty = {
1061 | type: 'Property',
1062 | computed: !literalOrIdentifier,
1063 | key: literalOrIdentifier || name.value,
1064 | value: {
1065 | type: 'ObjectExpression',
1066 | properties: element.properties.filter(property => property !== name)
1067 | }
1068 | };
1069 | subproperty.value.properties.push(fieldProperty);
1070 | } else if (element.type === 'SpreadElement') {
1071 | subproperty.value.properties.push({
1072 | type: 'SpreadElement',
1073 | argument: invokeHelper('arrayOptionToObject', element.argument)
1074 | });
1075 | } else {
1076 | throw unsupported();
1077 | }
1078 | }
1079 | } else {
1080 | throw unsupported();
1081 | }
1082 | } catch (e) {
1083 | if (e.name !== 'unsupported') {
1084 | throw e;
1085 | }
1086 | // If there is anything we don't understand at compile time,
1087 | // insert a call to a helper function that can
1088 | // make sense of it at runtime
1089 | subproperty.value = invokeHelper('arrayOptionToObject', value);
1090 | }
1091 | fields.value.properties.push(subproperty);
1092 | }
1093 |
1094 | // Given a function name and zero or more escodegen expressions as arguments,
1095 | // returns an escodegen expression that invokes the named function
1096 | // with the given arguments
1097 | function invokeHelper(name, ...args) {
1098 | if (!helpersNeeded) {
1099 | helpersNeeded = true;
1100 | projectHelpersNeeded = true;
1101 | prologue.push({
1102 | type: 'VariableDeclaration',
1103 | kind: 'const',
1104 | declarations: [
1105 | {
1106 | type: 'VariableDeclarator',
1107 | id: {
1108 | type: 'Identifier',
1109 | name: 'aposCodeMigrationHelpers'
1110 | },
1111 | init: {
1112 | type: 'CallExpression',
1113 | callee: {
1114 | type: 'Identifier',
1115 | name: 'require'
1116 | },
1117 | arguments: [
1118 | {
1119 | type: 'Literal',
1120 | value: '../../lib/apostrophe-code-migration-helpers.js'
1121 | }
1122 | ]
1123 | }
1124 | }
1125 | ]
1126 | });
1127 | }
1128 | return {
1129 | type: 'CallExpression',
1130 | callee: {
1131 | type: 'MemberExpression',
1132 | object: {
1133 | type: 'Identifier',
1134 | name: 'aposCodeMigrationHelpers'
1135 | },
1136 | property: {
1137 | type: 'Identifier',
1138 | name: 'arrayOptionToObject'
1139 | }
1140 | },
1141 | arguments: args
1142 | };
1143 | }
1144 |
1145 | }
1146 |
1147 | function injectHelpersIfNeeded() {
1148 | if (projectHelpersNeeded) {
1149 | if (!fs.existsSync('./lib')) {
1150 | fs.mkdirSync('./lib');
1151 | }
1152 | fs.writeFileSync('./lib/apostrophe-code-migration-helpers.js', fs.readFileSync(path.join(__dirname, '../helpers/index.js'), 'utf8'));
1153 | }
1154 | }
1155 |
1156 | };
1157 |
1158 | function filterModuleName(name) {
1159 | return legacyModuleNameMap[name] || name;
1160 | }
1161 |
1162 | function rename(oldpath, newpath) {
1163 | fs.mkdirSync(path.dirname(newpath), {
1164 | recursive: true
1165 | });
1166 | // Use git mv so that git status is less confusing
1167 | // and "git checkout ." knows what to do if used
1168 | const result = cp.spawnSync('git', [ 'mv', `./${oldpath}`, `./${newpath}` ], { encoding: 'utf8' });
1169 | if (result.status !== 0) {
1170 | throw result.stderr;
1171 | }
1172 | }
1173 |
1174 | function isSingleSiteProject() {
1175 | return fs.existsSync('app.js') && fs.existsSync('lib/modules');
1176 | }
1177 |
1178 | function isMultiSiteProject() {
1179 | return fs.existsSync('app.js') &&
1180 | fs.existsSync(('dashboard/lib/modules')) &&
1181 | fs.existsSync(('sites/lib/modules'));
1182 | }
1183 |
1184 | function isSingleNpmModule() {
1185 | // There is no way to be absolutely sure it isn't some unrelated
1186 | // kind of npm module, but this is a good sanity check
1187 | return fs.existsSync('package.json') &&
1188 | fs.existsSync('index.js') &&
1189 | !fs.readFileSync('index.js', 'utf8').includes('moogBundle');
1190 | }
1191 |
1192 | function isMoogBundle() {
1193 | return fs.existsSync('package.json') &&
1194 | fs.existsSync('index.js') &&
1195 | fs.readFileSync('index.js', 'utf8').includes('moogBundle');
1196 | }
1197 |
1198 | function nameToLiteralOrIdentifier(name) {
1199 | if (name.value.computed) {
1200 | return false;
1201 | }
1202 | if (name.value.type === 'Identifier') {
1203 | return name.value;
1204 | }
1205 | if (name.value.type === 'Literal') {
1206 | // Where possible convert to identifier
1207 | const text = name.value.value;
1208 | if (text.match(/^[a-zA-Z]\w*$/)) {
1209 | return {
1210 | type: 'Identifier',
1211 | name: text
1212 | };
1213 | } else {
1214 | return name.value;
1215 | }
1216 | }
1217 | return false;
1218 | }
1219 |
1220 | function unsupported() {
1221 | const e = new Error('Unsupported');
1222 | e.name = 'unsupported';
1223 | return e;
1224 | }
1225 |
--------------------------------------------------------------------------------