├── 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 | [![CircleCI](https://circleci.com/gh/apostrophecms/code-upgrader/tree/master.svg?style=svg)](https://circleci.com/gh/apostrophecms/code-upgrader/tree/master) 2 | [![Chat on Discord](https://img.shields.io/discord/517772094482677790.svg)](https://chat.apostrophecms.org) 3 | 4 |

5 | 6 | 7 | ApostropheCMS logo 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 | --------------------------------------------------------------------------------