├── README.md ├── angular-seo-server.js └── angular-seo.js /README.md: -------------------------------------------------------------------------------- 1 | Angular-SEO 2 | ----------- 3 | 4 | SEO for AngularJS apps made easy. Based on [PhantomJS](http://phantomjs.org/) and [yearofmoo's article](http://www.yearofmoo.com/2012/11/angularjs-and-seo.html). 5 | 6 | This version has an updated with a detailed Nginx file that passes request Host information to phantomjs. 7 | 8 | So only one instance of phantomjs is required to server multiple static sites. 9 | 10 | Note: it can also be used to serve a single website if a URL is passed in start parameters 11 | 12 | Requirements 13 | ============ 14 | 15 | You will need [PhantomJS](http://phantomjs.org/) to make this work, as it will render the application to HTML. 16 | 17 | 18 | How to use 19 | ========== 20 | 21 | The solution is made of 3 parts: 22 | - small modification of your static HTML file 23 | - an AngularJS module, that you have to include and call 24 | - PhantomJS script 25 | 26 | 27 | Modifying your static HTML 28 | ========================== 29 | 30 | Just add this to your `` to enable AJAX indexing by the crawlers. 31 | ``` 32 | 33 | ``` 34 | 35 | AngularJS Module 36 | ================ 37 | 38 | Just include `angular-seo.js` and then add the `seo` module to you app: 39 | ``` 40 | angular.module('app', ['ng', 'seo']); 41 | ``` 42 | 43 | If you are using [RequireJS](http://requirejs.org/), the script will detect it and auto define itself *BUT* you will need to have an `angular` shim defined, as `angular-seo` requires it: 44 | ``` 45 | requirejs.config({ 46 | paths: { 47 | angular: 'http://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.3/angular.min', 48 | }, 49 | shim: { 50 | angular: { 51 | exports: 'angular' 52 | } 53 | } 54 | }); 55 | ``` 56 | 57 | Then you must call `$scope.htmlReady()` when you think the page is complete. This is nescessary because of the async nature of AngularJS (such as with AJAX calls). 58 | ``` 59 | function MyCtrl($scope) { 60 | Items.query({}, function(items) { 61 | $scope.items = items; 62 | $scope.htmlReady(); 63 | }); 64 | } 65 | ``` 66 | 67 | And that's all there is to do on the app side. 68 | 69 | 70 | PhantomJS Module 71 | ================ 72 | 73 | For the app to be properly rendered, you will need to run the `angular-seo-server.js` with PhantomJS. 74 | Make sure to disable caching: 75 | 76 | To serve a snapshot of a single website, use 77 | ``` 78 | $ phantomjs --disk-cache=no angular-seo-server.js [port] [URL prefix] 79 | ``` 80 | 81 | To serve multiple website using a single instance of phantomJS, just dont pass a URL prefix 82 | ``` 83 | $ phantomjs --disk-cache=no angular-seo-server.js [port] 84 | ``` 85 | 86 | 87 | `URL prefix` is the URL that will be prepended to the path the crawlers will try to get. 88 | 89 | Some examples: 90 | ``` 91 | $ phantomjs --disk-cache=no angular-seo-server.js 8888 http://localhost:8000/myapp 92 | $ phantomjs --disk-cache=no angular-seo-server.js 8888 file:///path/to/index.html 93 | ``` 94 | 95 | 96 | Testing the setup 97 | ================= 98 | 99 | Google and Bing replace `#!` (hashbang) with `?_escaped_fragment_=` so `htttp://localhost/app.html#!/route` becomes `htttp://localhost/app.html?_escaped_fragment_=/route`. 100 | 101 | So say you app is running on `http://localhost:8000/index.html` (works with `file://` URLs too). 102 | First, run PhantomJS: 103 | ``` 104 | $ phantomjs --disk-cache=no angular-seo-server.js 8888 http://localhost:8000/index.html 105 | Listening on 8888... 106 | Press Ctrl+C to stop. 107 | ``` 108 | 109 | Then try with cURL: 110 | ``` 111 | $ curl 'http://localhost:8888/?_escaped_fragment_=/route' 112 | ``` 113 | 114 | You should then have a complete, rendered HTML output. 115 | 116 | 117 | Running in behind Nginx (or other) 118 | ================================== 119 | 120 | If course you don't want regular users to see this, only crawlers. 121 | To detect that, just look for an `_escaped_fragment_` in the query args. 122 | 123 | For instance with Nginx: 124 | ``` 125 | # Production config 126 | server { 127 | server_name <%= server_name %> default; 128 | listen <%= server_port %>; 129 | 130 | location / { 131 | proxy_set_header X-Real-IP $remote_addr; 132 | proxy_set_header X-Forwarded-For $remote_addr; 133 | proxy_set_header Host $http_host; 134 | 135 | # If not search enging pass static 136 | root "<%= base_dir %>"; 137 | 138 | if ($args ~ _escaped_fragment_) { 139 | proxy_pass http://localhost:8888; 140 | break; 141 | } 142 | 143 | try_files $uri $uri/ /index.html; 144 | } 145 | } 146 | ``` -------------------------------------------------------------------------------- /angular-seo-server.js: -------------------------------------------------------------------------------- 1 | var system = require('system'); 2 | 3 | if (system.args.length == 3) { 4 | console.log("NOTE: Running in single site mode, snapping only "+system.args[2]); 5 | } else if (system.args.length == 2) { 6 | console.log("NOTE: Running in Nginx mode, snapping urls in Host header"); 7 | } else { 8 | console.log("Missing arguments."); 9 | phantom.exit(); 10 | } 11 | 12 | var server = require('webserver').create(); 13 | var port = parseInt(system.args[1]); 14 | 15 | var renderHtml = function(url, cb) { 16 | var page = require('webpage').create(); 17 | page.settings.loadImages = false; 18 | page.settings.localToRemoteUrlAccessEnabled = true; 19 | 20 | page.onCallback = function() { 21 | cb(page.content); 22 | page.close(); 23 | }; 24 | 25 | // page.onConsoleMessage = function(msg, lineNum, sourceId) { 26 | // console.log('CONSOLE: ' + msg + ' (from line #' + lineNum + ' in "' + sourceId + '")'); 27 | // }; 28 | 29 | page.onInitialized = function() { 30 | page.evaluate(function() { 31 | setTimeout(function() { 32 | window.callPhantom(); 33 | }, 10000); 34 | }); 35 | }; 36 | 37 | page.open(url); 38 | }; 39 | 40 | server.listen(port, function (request, response) { 41 | var host = request.headers.Host; 42 | var urlPrefix = (typeof system.args[2] == 'undefined') ? 'http://' + host : system.args[2]; 43 | var route = request.url.replace("?_escaped_fragment_=","#"); 44 | var url = urlPrefix + decodeURIComponent(route); 45 | 46 | 47 | renderHtml(url , function(html) { 48 | response.statusCode = 200; 49 | response.write(html); 50 | response.close(); 51 | }); 52 | }); 53 | 54 | console.log('Listening on ' + port + '...'); 55 | console.log('Press Ctrl+C to stop.'); -------------------------------------------------------------------------------- /angular-seo.js: -------------------------------------------------------------------------------- 1 | !function(window, document, undefined) { 2 | var getModule = function(angular) { 3 | return angular.module('seo', []) 4 | .run([ 5 | '$rootScope', 6 | function($rootScope) { 7 | $rootScope.htmlReady = function() { 8 | $rootScope.$evalAsync(function() { // fire after $digest 9 | setTimeout(function() { // fire after DOM rendering 10 | if (typeof window.callPhantom == 'function') { 11 | window.callPhantom(); 12 | } 13 | }, 0); 14 | }); 15 | }; 16 | } 17 | ]); 18 | }; 19 | if (typeof define == 'function' && define.amd) 20 | define(['angular'], getModule); 21 | else 22 | getModule(angular); 23 | }(window, document); --------------------------------------------------------------------------------