CLI: Add support for templates fetched from npm

Summary:
This PR allows anyone to publish templates for React Native.

It's possible for people to publish modules for React Native, we should also support custom templates. A suggestion from a Cordova mantainer where they did the same thing suggests this is useful:
https://github.com/mkonicek/AppTemplateFeedback/issues/1

I published a sample template [react-native-template-demo](https://www.npmjs.com/package/react-native-template-demo).

(GitHub: https://github.com/mkonicek/react-native-template-demo)

With this PR anyone can then use that template:

`react-native init MyApp --template demo`

The convention is: if someone publishes an npm package called `react-native-template-foo`, people can use it by running `react-native init MyApp --template foo`.

Use a template called `react-native-template-demo` from npm:

`react-native init MyApp --template demo`

Use a local template:

`react-native init MyApp --template file:///path_to/react-native-template-dem
Closes https://github.com/facebook/react-native/pull/12548

Differential Revision: D4620567

Pulled By: mkonicek

fbshipit-source-id: bb40d457a7fec28edb577f08137e73241072de3a
This commit is contained in:
Martin Konicek 2017-02-27 09:12:14 -08:00 committed by Facebook Github Bot
parent 37b91a63c7
commit 17c175a149
3 changed files with 168 additions and 50 deletions

View File

@ -21,6 +21,12 @@ const walk = require('../util/walk');
* @param srcPath e.g. '/Users/martin/AwesomeApp/node_modules/react-native/local-cli/templates/HelloWorld' * @param srcPath e.g. '/Users/martin/AwesomeApp/node_modules/react-native/local-cli/templates/HelloWorld'
* @param destPath e.g. '/Users/martin/AwesomeApp' * @param destPath e.g. '/Users/martin/AwesomeApp'
* @param newProjectName e.g. 'AwesomeApp' * @param newProjectName e.g. 'AwesomeApp'
* @param options e.g. {
* upgrade: true,
* force: false,
* displayName: 'Hello World',
* ignorePaths: ['template/file/to/ignore.md'],
* }
*/ */
function copyProjectTemplateAndReplace(srcPath, destPath, newProjectName, options) { function copyProjectTemplateAndReplace(srcPath, destPath, newProjectName, options) {
if (!srcPath) { throw new Error('Need a path to copy from'); } if (!srcPath) { throw new Error('Need a path to copy from'); }
@ -45,6 +51,20 @@ function copyProjectTemplateAndReplace(srcPath, destPath, newProjectName, option
.replace(/HelloWorld/g, newProjectName) .replace(/HelloWorld/g, newProjectName)
.replace(/helloworld/g, newProjectName.toLowerCase()); .replace(/helloworld/g, newProjectName.toLowerCase());
// Templates may contain files that we don't want to copy.
// Examples:
// - Dummy package.json file included in the template only for publishing to npm
// - Docs specific to the template (.md files)
if (options.ignorePaths) {
if (!Array.isArray(options.ignorePaths)) {
throw new Error('options.ignorePaths must be an array');
}
if (options.ignorePaths.some(ignorePath => ignorePath === relativeFilePath)) {
// Skip copying this file
return;
}
}
let contentChangedCallback = null; let contentChangedCallback = null;
if (options.upgrade && (!options.force)) { if (options.upgrade && (!options.force)) {
contentChangedCallback = (_, contentChanged) => { contentChangedCallback = (_, contentChanged) => {

View File

@ -13,7 +13,10 @@ const execSync = require('child_process').execSync;
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const availableTemplates = { /**
* Templates released as part of react-native in local-cli/templates.
*/
const builtInTemplates = {
navigation: 'HelloNavigation', navigation: 'HelloNavigation',
}; };
@ -22,9 +25,9 @@ function listTemplatesAndExit(newProjectName, options) {
// Just listing templates using 'react-native init --template'. // Just listing templates using 'react-native init --template'.
// Not creating a new app. // Not creating a new app.
// Print available templates and exit. // Print available templates and exit.
const templateKeys = Object.keys(availableTemplates); const templateKeys = Object.keys(builtInTemplates);
if (templateKeys.length === 0) { if (templateKeys.length === 0) {
// Just a guard, should never happen as long availableTemplates // Just a guard, should never happen as long builtInTemplates
// above is defined correctly :) // above is defined correctly :)
console.log( console.log(
'There are no templates available besides ' + 'There are no templates available besides ' +
@ -47,12 +50,13 @@ function listTemplatesAndExit(newProjectName, options) {
} }
/** /**
* @param destPath Create the new project at this path.
* @param newProjectName For example 'AwesomeApp'. * @param newProjectName For example 'AwesomeApp'.
* @param templateKey Template to use, for example 'navigation'. * @param template Template to use, for example 'navigation'.
* @param yarnVersion Version of yarn available on the system, or null if * @param yarnVersion Version of yarn available on the system, or null if
* yarn is not available. For example '0.18.1'. * yarn is not available. For example '0.18.1'.
*/ */
function createProjectFromTemplate(destPath, newProjectName, templateKey, yarnVersion) { function createProjectFromTemplate(destPath, newProjectName, template, yarnVersion) {
// Expand the basic 'HelloWorld' template // Expand the basic 'HelloWorld' template
copyProjectTemplateAndReplace( copyProjectTemplateAndReplace(
path.resolve('node_modules', 'react-native', 'local-cli', 'templates', 'HelloWorld'), path.resolve('node_modules', 'react-native', 'local-cli', 'templates', 'HelloWorld'),
@ -60,52 +64,140 @@ function createProjectFromTemplate(destPath, newProjectName, templateKey, yarnVe
newProjectName newProjectName
); );
if (templateKey !== undefined) { if (template === undefined) {
// Keep the files from the 'HelloWorld' template, and overwrite some of them // No specific template, use just the HelloWorld template above
// with the specified project template. return;
// The 'HelloWorld' template contains the native files (these are used by }
// all templates) and every other template only contains additional JS code.
// Reason: // Keep the files from the 'HelloWorld' template, and overwrite some of them
// This way we don't have to duplicate the native files in every template. // with the specified project template.
// If we duplicated them we'd make RN larger and risk that people would // The 'HelloWorld' template contains the native files (these are used by
// forget to maintain all the copies so they would go out of sync. // all templates) and every other template only contains additional JS code.
const templateName = availableTemplates[templateKey]; // Reason:
if (templateName) { // This way we don't have to duplicate the native files in every template.
copyProjectTemplateAndReplace( // If we duplicated them we'd make RN larger and risk that people would
path.resolve( // forget to maintain all the copies so they would go out of sync.
'node_modules', 'react-native', 'local-cli', 'templates', templateName const builtInTemplateName = builtInTemplates[template];
), if (builtInTemplateName) {
destPath, // template is e.g. 'navigation',
newProjectName // use the built-in local-cli/templates/HelloNavigation folder
); createFromBuiltInTemplate(builtInTemplateName, destPath, newProjectName, yarnVersion);
} else {
// template is e.g. 'ignite',
// use the template react-native-template-ignite from npm
createFromRemoteTemplate(template, destPath, newProjectName, yarnVersion);
}
}
// (We might want to get rid of built-in templates in the future -
// publish them to npm and install from there.)
function createFromBuiltInTemplate(templateName, destPath, newProjectName, yarnVersion) {
const templatePath = path.resolve(
'node_modules', 'react-native', 'local-cli', 'templates', templateName
);
copyProjectTemplateAndReplace(
templatePath,
destPath,
newProjectName,
);
installTemplateDependencies(templatePath, yarnVersion);
}
/**
* The following formats are supported for the template:
* - 'demo' -> Fetch the package react-native-template-demo from npm
* - git://..., http://..., file://... or any other URL supported by npm
*/
function createFromRemoteTemplate(template, destPath, newProjectName, yarnVersion) {
let installPackage;
let templateName;
if (template.includes('://')) {
// URL, e.g. git://, file://
installPackage = template;
templateName = template.substr(template.lastIndexOf('/') + 1);
} else {
// e.g 'demo'
installPackage = 'react-native-template-' + template;
templateName = installPackage;
}
// Check if the template exists
console.log(`Fetching template ${installPackage}...`);
try {
if (yarnVersion) {
execSync(`yarn add ${installPackage} --ignore-scripts`, {stdio: 'inherit'});
} else { } else {
throw new Error('Uknown template: ' + templateKey); execSync(`npm install ${installPackage} --save --save-exact --ignore-scripts`, {stdio: 'inherit'});
} }
const templatePath = path.resolve(
// Add dependencies: 'node_modules', templateName
// dependencies.json is a special file that lists additional dependencies
// that are required by this template
const dependenciesJsonPath = path.resolve(
'node_modules', 'react-native', 'local-cli', 'templates', templateName, 'dependencies.json'
); );
if (fs.existsSync(dependenciesJsonPath)) { copyProjectTemplateAndReplace(
console.log('Adding dependencies for the project...'); templatePath,
const dependencies = JSON.parse(fs.readFileSync(dependenciesJsonPath)); destPath,
for (let depName in dependencies) { newProjectName,
const depVersion = dependencies[depName]; {
const depToInstall = depName + '@' + depVersion; // Every template contains a dummy package.json file included
console.log('Adding ' + depToInstall + '...'); // only for publishing the template to npm.
if (yarnVersion) { // We want to ignore this dummy file, otherwise it would overwrite
execSync(`yarn add ${depToInstall}`, {stdio: 'inherit'}); // our project's package.json file.
} else { ignorePaths: ['package.json', 'dependencies.json'],
execSync(`npm install ${depToInstall} --save --save-exact`, {stdio: 'inherit'});
}
} }
);
installTemplateDependencies(templatePath, yarnVersion);
} finally {
// Clean up the temp files
try {
if (yarnVersion) {
execSync(`yarn remove ${templateName} --ignore-scripts`);
} else {
execSync(`npm uninstall ${templateName} --ignore-scripts`);
}
} catch (err) {
// Not critical but we still want people to know and report
// if this the clean up fails.
console.warn(
`Failed to clean up template temp files in node_modules/${templateName}. ` +
'This is not a critical error, you can work on your app.'
);
} }
} }
} }
function installTemplateDependencies(templatePath, yarnVersion) {
// dependencies.json is a special file that lists additional dependencies
// that are required by this template
const dependenciesJsonPath = path.resolve(
templatePath, 'dependencies.json'
);
console.log('Adding dependencies for the project...');
if (!fs.existsSync(dependenciesJsonPath)) {
console.log('No additional dependencies.');
return;
}
let dependencies;
try {
dependencies = JSON.parse(fs.readFileSync(dependenciesJsonPath));
} catch (err) {
throw new Error(
'Could not parse the template\'s dependencies.json: ' + err.message
);
}
for (let depName in dependencies) {
const depVersion = dependencies[depName];
const depToInstall = depName + '@' + depVersion;
console.log('Adding ' + depToInstall + '...');
if (yarnVersion) {
execSync(`yarn add ${depToInstall}`, {stdio: 'inherit'});
} else {
execSync(`npm install ${depToInstall} --save --save-exact`, {stdio: 'inherit'});
}
}
console.log('Linking native dependencies into the project\'s build files...');
execSync('react-native link', {stdio: 'inherit'});
}
module.exports = { module.exports = {
listTemplatesAndExit, listTemplatesAndExit,
createProjectFromTemplate, createProjectFromTemplate,

View File

@ -5,6 +5,8 @@
* This source code is licensed under the BSD-style license found in the * This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant * LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory. * of patent rights can be found in the PATENTS file in the same directory.
*
* @flow
*/ */
const log = require('npmlog'); const log = require('npmlog');
@ -29,6 +31,8 @@ const pollParams = require('./pollParams');
const commandStub = require('./commandStub'); const commandStub = require('./commandStub');
const promisify = require('./promisify'); const promisify = require('./promisify');
import type {ConfigT} from '../core';
log.heading = 'rnpm-link'; log.heading = 'rnpm-link';
const dedupeAssets = (assets) => uniq(assets, asset => path.basename(asset)); const dedupeAssets = (assets) => uniq(assets, asset => path.basename(asset));
@ -125,18 +129,20 @@ const linkAssets = (project, assets) => {
}; };
/** /**
* Updates project and links all dependencies to it * Updates project and links all dependencies to it.
* *
* If optional argument [packageName] is provided, it's the only one that's checked * @param args If optional argument [packageName] is provided,
* only that package is processed.
* @param config CLI config, see local-cli/core/index.js
*/ */
function link(args, config) { function link(args: Array<string>, config: ConfigT) {
var project; var project;
try { try {
project = config.getProjectConfig(); project = config.getProjectConfig();
} catch (err) { } catch (err) {
log.error( log.error(
'ERRPACKAGEJSON', 'ERRPACKAGEJSON',
'No package found. Are you sure it\'s a React Native project?' 'No package found. Are you sure this is a React Native project?'
); );
return Promise.reject(err); return Promise.reject(err);
} }
@ -169,8 +175,8 @@ function link(args, config) {
return promiseWaterfall(tasks).catch(err => { return promiseWaterfall(tasks).catch(err => {
log.error( log.error(
`It seems something went wrong while linking. Error: ${err.message} \n` `Something went wrong while linking. Error: ${err.message} \n` +
+ 'Please file an issue here: https://github.com/facebook/react-native/issues' 'Please file an issue here: https://github.com/facebook/react-native/issues'
); );
throw err; throw err;
}); });
@ -178,6 +184,6 @@ function link(args, config) {
module.exports = { module.exports = {
func: link, func: link,
description: 'links all native dependencies', description: 'links all native dependencies (updates native build files)',
name: 'link [packageName]', name: 'link [packageName]',
}; };